Skip to content

fix(runtime-broker): close cross-process release race and harden scheduling - #13214

Merged
wenshao merged 16 commits into
mainfrom
fix/13183-runtime-broker-hardening
Oct 4, 2026
Merged

wenshao merged 16 commits into
mainfrom
fix/13183-runtime-broker-hardening

Conversation

@wenshao

@wenshao wenshao commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Fixes the three audit-verified high-risk findings from #13183 plus the two low-cost items of its medium cluster. Release and admission are now decided in one transaction: the RELEASING transition re-checks active executions under the same Session row lock that admission takes, so two Broker processes sharing one database can no longer produce an active execution under a RELEASED session. Lease renewals move off the single coordination thread (which also ran retries, fences, and polls) onto a dedicated two-thread pool, so one stalled JDBC call can no longer fence unrelated healthy bindings; v3 result polling backs off exponentially (100 ms doubling to a 2 s cap) with the polling window now configurable (v3-result-window, a deadline for the post-dispatch result poll rather than a result-retention period); and automatic UNKNOWN observations reuse the freshest lookup for a one-second cooldown instead of fanning every sequential poll through to the worker — the explicit reconcile=true path still asks every time. The HTTP face refuses non-loopback listen addresses unless the deployment opts in (allow-non-loopback), since it serves one global Bearer token over plaintext HTTP. Non-durable worker shutdown escalates destroy() through a bounded grace window to destroyForcibly(), and a JVM exit hook covers workers too — including ones still in their ready handshake, which are tracked from spawn. A LOST reclaim now loops the bounded 100-row recovery passes under a 16-pass budget instead of answering 503 past 300 sessions, and a generation larger than one budget answers runtime_broker_runtime_lost for the next reclaim to resume.

The remaining medium items are split out as #13202 (credential key ring), #13203 (terminal-row retention), and #13204 (InMemory/JDBC semantic alignment).

Why it's needed

#13183 documents, with reproductions: a cross-process admit/release race that lets a released worker keep running an execution it was told to drop; a single-thread scheduler that turns a 1–2 s storage stall into cross-binding fencing; rapid sequential polling of an UNKNOWN execution fanning every request through to the worker; and a single global token over plaintext HTTP that any peer can use once the host widens past loopback.

Reviewer Test Plan

How to verify

cd packages/sdk-java/runtime-broker && mvn clean test (JDK 21) — 635 tests green, including the new Issue13183RegressionTest (25) and Issue13183AdversarialTest (6). The two classes encode the issue's scenarios with the fixed expectations: the reproduction interleaving of the cross-process race now gets 409 runtime_session_busy (and the reverse order gets runtime_admission_closed); a stalled renewal fences only its own binding while an unrelated binding provisions READY; three rapid GETs of an UNKNOWN execution cost one worker status call; a LOST generation with 303 sessions (and a mixed one with 150 executions + 251 sessions) drains in one warm; a non-loopback bind is refused unless opted in; a SIGTERM-ignoring worker is forcibly destroyed after release, on close(), and on JVM exit mid-handshake (forked-JVM proof). mvn checkstyle:check is clean. In packages/sdk-java/managed-agent-server, mvn test -Dtest='EmbeddedRuntimeBrokerTest,ManagedAgentPropertiesTest' passes (19 tests), covering the new allow-non-loopback/v3-result-window wiring.

Evidence (Before & After)

N/A — non-UI change. Before/after behavior is pinned by the regression tests: the reproduction harness (same interleavings) fails on the base commit and passes on this branch.

Tested on

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

Environment (optional)

JDK 21 + Maven 3.9.16, H2 in MySQL mode. The module's mysql-integration failsafe profile re-runs the repository layer against a real MySQL; it was not run here.

Risk & Scope

Linked Issues

Refs #13183 — the high-risk findings and two medium items are fixed here; the remaining medium items continue as #13202, #13203, #13204.

中文说明

这个 PR 做了什么

修复 #13183 中经审计复现的三个高危发现和 Medium 簇里两个低成本项。释放与准入现在在同一事务内判定:RELEASING 转换在与准入相同的 Session 行锁下复查活跃 execution,共享一个数据库的两个 Broker 进程不再可能出现"execution 活跃而 session 已 RELEASED"的矛盾态。租约续约从单条协调线程(原来还跑重试、围栏和轮询)迁到独立双线程池,一次卡住的 JDBC 调用不再把无关健康 binding 围栏;v3 结果轮询改为指数退避(100ms 倍增、2s 封顶),轮询窗口可配置(v3-result-window,它是派发后结果轮询的截止期,不是结果保留期);UNKNOWN 执行的自动观测在 1 秒冷却内复用最近一次查询结果,不再把每次顺序轮询穿透到 worker——显式 reconcile=true 路径仍然每次都问。HTTP 面默认拒绝非回环监听地址,除非部署方显式开启(allow-non-loopback),因为它以明文 HTTP 服务单一全局 Bearer token。非 durable worker 的关停从 destroy() 经有界宽限期升级到 destroyForcibly(),JVM 退出钩子同样覆盖尚在 ready 握手期的 worker(从 spawn 起即被跟踪)。LOST 回收现在在 16 趟预算内循环驱动有界的 100 行恢复批次,而不是超过 300 个 session 就回 503;超出单次预算的代际回答 runtime_broker_runtime_lost,由下一次 reclaim 继续。

其余 Medium 项拆分为 #13202(凭证 key ring)、#13203(终态行保留作业)、#13204(InMemory/JDBC 语义对齐)。

为什么需要

#13183 带复现地记录了:跨进程 admit/release 竞态让已被通知释放的 worker 继续跑 execution;单线程调度器把 1-2 秒的存储抖动放大成跨 binding 围栏;对 UNKNOWN 执行的高频顺序轮询把每个请求都穿透到 worker;单一全局 token 加明文 HTTP 在监听地址放开回环后可被任何对端利用。

评审者测试计划

如何验证

cd packages/sdk-java/runtime-broker && mvn clean test(JDK 21)——635 个测试全绿,含新增的 Issue13183RegressionTest(25 个)与 Issue13183AdversarialTest(6 个)。两个测试类以修复后的期望编码了 issue 的场景:跨进程竞态的复现交错现在得到 409 runtime_session_busy(反向顺序得到 runtime_admission_closed);卡住的续约只围栏自身 binding,无关 binding 正常 READY;对 UNKNOWN 执行的 3 次快速连续 GET 只产生 1 次 worker status 调用;303 个 session 的 LOST 代际(以及 150 execution + 251 session 的混合代际)在一次 warm 内排空;非回环绑定被拒绝、显式 opt-in 放行;无视 SIGTERM 的 worker 在 release、close() 与 JVM 退出中途(forked-JVM 实证)都被强杀。mvn checkstyle:check 干净。在 packages/sdk-java/managed-agent-server 中 mvn test -Dtest='EmbeddedRuntimeBrokerTest,ManagedAgentPropertiesTest' 通过(19 个),覆盖新的 allow-non-loopback/v3-result-window 接线。

前后对比证据

N/A —— 非 UI 变更。前后行为由回归测试钉住:同一套复现交错在基线提交上失败、在本分支上通过。

测试平台

macOS ✅;Windows ⚠️ 未测;Linux ⚠️ 未测。

环境(可选)

JDK 21 + Maven 3.9.16,H2 MySQL 模式。模块自带的 mysql-integration failsafe profile 可对真实 MySQL 复跑仓库层,此处未运行。

风险与范围

关联 Issue

Refs #13183——高危发现与两个 Medium 项在此修复;其余 Medium 项在 #13202、#13203、#13204 跟进。

…duling

The release decision and the no-active-execution check now commit in one
transaction under the Session row lock that admission also takes, so two
Broker processes sharing a database can no longer interleave an admission
into a release. Lease renewals move off the single coordination thread
onto a dedicated two-thread pool, v3 result polling backs off
exponentially (100 ms doubling to a 5 s cap, with a configurable window),
and automatic UNKNOWN observations share the freshest lookup for a
one-second cooldown instead of fanning every poll through to the worker.

The HTTP listener refuses non-loopback bind addresses unless the
deployment opts in (allow-non-loopback), non-durable worker shutdown
escalates destroy() through a bounded grace window to destroyForcibly()
with a JVM exit hook that also covers workers still in their ready
handshake, and a LOST reclaim loops the bounded 100-row recovery passes
until the generation drains instead of failing past 300 sessions.

Refs #13183
@wenshao

wenshao commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Verification report

Reproduce-first, then fix, then verify — same Maven harness throughout (JDK 21, Maven 3.9.16, H2 in MODE=MySQL; the raced constructs — FOR UPDATE row locks, version-guarded CAS, separate transactions — behave identically to MySQL there, and the module ships a mysql-integration failsafe profile for the real engine).

  • The three high-risk findings were each reproduced deterministically against the base commit before any fix (two repository stacks over one database standing in for two Broker processes; a parked renewal JDBC call; real HTTP socket binds), and the same scenarios pass with flipped assertions on this branch.
  • packages/sdk-java/runtime-broker: mvn clean test — 578 tests, 0 failures/errors. New regression coverage: Issue13183RegressionTest (11) and Issue13183AdversarialTest (4, including a 200-round cross-process admit/release stress and a forked-JVM exit-hook proof).
  • mvn checkstyle:check clean. packages/sdk-java/managed-agent-server fix-adjacent suites green (16 tests).
  • The fix went through /review plus three adversarial audit rounds after the initial verification; they caught two real issues the first pass missed (workers spawned-but-unregistered being invisible to the exit hook/close during the ready handshake; a spawn↔register orphan window under an exit-hook-only shutdown), both fixed and covered by tests. The last two audit rounds were clean.
  • Pre-existing, fix-independent flakes on this machine (confirmed failing on the base commit under load, passing in isolation): DurableRuntimeRecoveryTest.lostBranchReclaimsBeyondTheReconcileDeadline, two ToolPublicationStoreTest cases.
中文

先复现、再修复、后验证——全程同一 Maven 工具链(JDK 21、Maven 3.9.16、H2 MODE=MySQL;竞态所涉构造(行锁 FOR UPDATE、版本 CAS、跨事务)与 MySQL 语义一致,模块自带 mysql-integration failsafe profile 可对真实引擎复跑)。

  • 三个高危发现均先在基线提交上确定性复现(两套仓库栈共享一个库模拟两个 Broker 进程、卡住的续约 JDBC、真实 HTTP 绑定),同场景在本分支上以翻转断言通过。
  • packages/sdk-java/runtime-broker:mvn clean test —— 578 个测试全绿。新增回归:Issue13183RegressionTest(11)与 Issue13183AdversarialTest(4,含 200 轮跨进程 admit/release 对撞与 forked-JVM 退出钩子实证)。
  • mvn checkstyle:check 干净;packages/sdk-java/managed-agent-server 修复邻近套件全绿(16 个)。
  • 首轮验证后又经过 /review 与三轮对抗审计,捕获并修复了首轮遗漏的两个真实问题(已 spawn 未注册的 worker 在 ready 握手期对退出钩子/close() 不可见;hook-only 关停下 spawn↔注册孤儿窗口),均有测试覆盖;最后两轮审计干净。
  • 本机既有、与修复无关的 flake(基线提交在负载下同样失败、单独运行通过):DurableRuntimeRecoveryTest.lostBranchReclaimsBeyondTheReconcileDeadline 与两个 ToolPublicationStoreTest 用例。

…rves, and reclaim bounds

Review round on #13214. The cooldown cache-hit path now replays a settled
reconciliation whole instead of pairing it with a caller's stale UNKNOWN
record; mutation responses (:start/:cancel) bypass the cooldown so their
answers describe post-mutation truth; the v3 result window has a one-second
floor (a suffix-less config value binds as milliseconds); the LOST drain
loop measures its baseline and stops after 16 bounded passes, answering
runtime_broker_runtime_lost for larger generations so a later reclaim
resumes; the redundant pre-flight execution check is dropped from
releaseSession (the transition's own transaction answers the same 409;
the cooldown eviction consults the service clock; the dead lookupOnce
overload is removed; and the design documents record the mechanism in both
languages, with a new hardening design for this change.

Tests: contract coverage for beginSessionRelease on both repository
backends, a service-level cross-process release race, the settled-answer
replay guard, per-execution cooldown scoping, the v3 window bound, the
dispatch renewal pool pin, the provision-during-close guard, the pass
budget, and the --ignore-term wedge; shared fixtures are deduplicated and
the new classes carry suite-level timeouts.
EOF
)
@wenshao

wenshao commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Round-2 review dispositions (commit 34b3f07)

All 21 findings were addressed; each new guard now carries a mutation-proven test (mutant red, fix green).

Fixed:

  • R1-1 (Critical): the cache-hit path replays a settled/abandoned cached reconciliation whole; only a still-UNKNOWN cached record is paired with the caller's fresher one (cancel flag stays fresh). Pinned by cooledObservationNeverPairsASettledAnswerWithAStaleRecord (red when the guard is reverted).
  • R1-2: v3ResultWindow floors at MIN_V3_RESULT_WINDOW (1 s) in the service constructor and in EmbeddedRuntimeBroker before anything is built; a suffix-less 30 now fails startup instead of degrading v3 executions. Pinned by v3ResultWindowBelowTheFirstPollTickIsRefused + the broker test.
  • R1-4: :start/:cancel mutation responses bypass the cooldown (coolable=false); only plain GET polling is cooled. The v3 HTTP test catches a revert (cancel answered executing from cache → red).
  • R1-5: the drain loop measures its baseline (stall check fires on pass 1) and stops after 16 bounded passes per call, answering runtime_broker_runtime_lost so a later reclaim resumes; pinned by reclaimBeyondThePassBudgetAnswersLost (4801 sessions). Declined the orTimeout: the drain is synchronous JDBC on the caller's thread, so a timeout would free the caller while the loop keeps holding the operation claim — the pass budget is the honest bound.
  • R1-6: pinning tests added per guard — service-level cross-process release race (red when the wiring is reverted), verifyBeginSessionRelease contract on both backends (busy/not-ready/passthrough/stale), v3ResultWindowBoundsThePolling (red when the window is hardcoded), opt-in wildcard bind positive case, provisionDuringCloseNeverOrphansAWorker (red without the terminated guard), dispatchRenewalsRunOnTheRenewalPool (red if dispatch renewals move pools).
  • R1-7: cooldownIsScopedPerExecution — a second UNKNOWN execution in the same session is asked on its own first observation (red if the key widens to the session).
  • R1-8: the redundant pre-flight hasActiveByRuntimeSession is dropped; beginSessionRelease's in-transaction check answers the identical 409.
  • R1-9: dead lookupOnce overload deleted.
  • R1-10: new design doc docs/design/2026-10-02-runtime-broker-hardening.md (+ zh-CN); the stale statements in managed-runtime-broker-service-core and managed-runtime-broker-jdbc updated in both languages.
  • R1-11: shared fixtures deduplicated; ProcessTrees.childPids() reused.
  • R1-12: the inert control test deleted (the guard is pinned by the race test and the contract).
  • R1-13: the mixed-drain test asserts exactly 150 active executions (this caught a real fixture issue — PREPARED is outside findUnsettled, so the executions now run EXECUTING).
  • R1-14: gap assertions are now two-sided (lower + upper bounds).
  • R1-15: stalledRenewalTickDoesNotDeadlockClose brackets close() alone with a 5 s bound.
  • R1-16: bounded waits everywhere plus @Timeout on both new classes.
  • R1-17: FakeAttestationWorkerTest.ignoreTermSurvivesSigterm pins the wedge itself (red when reverted).
  • R1-18/19: the exit-hook test renamed to the post-fix behavior, harness output kept to a log file with the tail in failure messages.
  • R1-20: cooldown eviction consults the service clock, so the frozen-clock tests can't be evicted early by wall time.
  • R1-21: UNKNOWN_LOOKUP_COOLDOWN is package-visible and the helper reads it.

Declined with rationale:

  • R1-3: the 5 s cap is the load/latency trade-off the issue asked for; a result landing in the final poll gap flips UNKNOWN and is still recoverable through the existing reconcile path, same as the pre-PR 100 ms behavior. Kept.

Verification: mvn clean test in runtime-broker — 587 tests, the only failure being the known pre-existing lostBranchReclaimsBeyondTheReconcileDeadline flake (fails identically on the base commit under load, passes in isolation); mvn checkstyle:check clean; managed-agent-server fix-adjacent 17/17 green. A follow-up adversarial audit of this commit's delta came back clean on all five attack surfaces.

中文说明

第二轮评审的 21 条发现已全部处理(commit 34b3f07),每个新守卫都有变异验证过的测试(变异红、修复绿)。

已修复:R1-1(Critical,缓存命中回放规则)、R1-2(窗口 1s 下限)、R1-4(mutation 响应不走冷却)、R1-5(实测基线 + 16 趟预算;orTimeout 未采纳——排空是调用方线程上的同步 JDBC,超时只会让调用方解脱而循环仍持有 claim,预算才是诚实的边界)、R1-6(每个守卫的钉住测试)、R1-7(按执行隔离)、R1-8(删冗余预检)、R1-9(删死重载)、R1-10(双语设计文档)、R1-11(夹具去重)、R1-12(删失效控制测试)、R1-13(精确执行数,顺带修正了夹具的 PREPARED/EXECUTING 语义)、R1-14(双向断言)、R1-15(只测 close())、R1-16(有界等待 + 类级 @timeout)、R1-17(楔子自身钉住)、R1-18/19(措辞与日志留存)、R1-20(驱逐尊重服务时钟)、R1-21(常量包可见)。

未采纳并说明:R1-3(5s 封顶是 issue 要求的负载/时延取舍;末间隔内到达的结果转 UNKNOWN 后仍可经既有对账路径恢复,与改动前 100ms 轮询的语义一致)。

验证:runtime-broker clean 全量 587 个测试,唯一失败是已确认的存量 flake(lostBranchReclaimsBeyondTheReconcileDeadline,基线同现、单跑通过);checkstyle 干净;managed-agent-server 邻近 17/17 绿。本提交的增量经一轮对抗审计,五个攻击面全部干净。

wenshao added 2 commits October 3, 2026 07:35
…ker-hardening

# Conflicts:
#	packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRepository.java
The --ignore-term wedge test is POSIX-only (Windows CI has no SIGTERM to
swallow) and gains the mirror arm proving a default worker exits on
destroy(). A cooled observation now serves the fresher of the caller's
snapshot and the lookup's own re-read while both are UNKNOWN. The :start
mutation response's cache bypass is pinned by a discriminating test, the
budget test now proves the next reclaim resumes, and the
provision-during-close test matches the guard's exact message and stops
at pool shutdown. An orphaned import is dropped.
@wenshao

wenshao commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Round-3 review dispositions + merge (commit 3710ef7, merged with main at a011f66)

The branch merged main (the conflict was RuntimeBindingRepository's adjacent additions — the harness-drain methods and beginSessionRelease coexist; the merged tree's full suite passes, see below). Round-3 findings handled:

Fixed:

  • R2-1 (Critical): FakeAttestationWorkerTest.ignoreTermSurvivesSigterm now carries @DisabledOnOs(WINDOWS) — Windows CI has no SIGTERM to swallow; destroy() there is TerminateProcess. Verified against sdk-java.yml: the windows-latest / java 21 leg does run this module. (Not re-runnable locally on macOS; the guard is structural.)
  • R2-2: the cache-hit replay now serves the fresher of the caller's snapshot and the lookup's own re-read while both are UNKNOWN (a cancel landing between them stays visible). Pinned by cooledObservationServesTheFresherUnknownRecord — red when reverted to caller-wins.
  • R2-4: the orphaned Collectors import dropped.
  • R2-5: startResponsesNeverServeTheCooldownCache pins the :start half — a cooled "executing" stamp, then a :start retry inside the window answers "settled" from the worker. Red when the site's coolable flips.
  • R2-6: FakeAttestationWorkerTest.defaultWorkerExitsOnSigterm pins the mirror arm — a default worker dies on destroy() within 2 s.
  • R2-7: reclaimBeyondThePassBudgetAnswersLost now drives a second warm after the 503 and asserts the reclaim resumes: the generation drains and a new generation is READY.
  • R2-8: provisionDuringCloseNeverOrphansAWorker matches the guard's exact message ("Managed Runtime provisioner is closed." — no longer confused with "closed before ready"), catches the RejectedExecutionException a post-shutdown submission throws, and stops polling once the closer exits.

Declined with rationale:

  • R2-3: the cooldown window is deliberately measured in the service clock (the same clock that owns every other deadline in the service); a backward wall-clock step stretching the window is consistent with that. The eviction task is hygiene only — the read-side deadline check is the authority, so a never-firing eviction cannot serve an expired answer. Entries are bounded by distinct execution ids (re-stamps overwrite the same key).

Deferred items from the round-3 body (v3-window forwarding witness, renewalScheduler.shutdownNow witness, harness constructor cleanup) were recorded by the review as not-requested this round; left as-is.

Verification on the merged tree (34b3f07f + merge + this round): mvn clean test in runtime-broker — 620 tests, the only failure the known pre-existing lostBranchReclaimsBeyondTheReconcileDeadline flake (fails identically on base under load, passes in isolation); managed-agent-server fix-adjacent 17/17 green; the R2-2 and R2-5 pins were each proven red under their mutations before landing green.

中文说明

第三轮评审处置 + 与 main 的合并(提交 3710ef7,合并点为 a011f66):

冲突为 RuntimeBindingRepository 的同位置新增(main 的 harness-drain 三方法与 beginSessionRelease 共存);合并树全量套件通过。

已修复:R2-1(Critical,Windows 无 SIGTERM,wedge 测试加 @DisabledOnOs(WINDOWS))、R2-2(冷却回放在双方均 UNKNOWN 时取较新记录,取消标记不丢)、R2-4(孤儿 import)、R2-5(:start 绕行冷却的判别测试)、R2-6(默认 worker 被 SIGTERM 终止的镜像钉住)、R2-7(预算测试补"下一次 reclaim 续排成功")、R2-8(oracle 精确匹配守卫消息 + 捕获 RejectedExecutionException + 循环退出条件)。

未采纳并说明:R2-3(冷却窗口按服务时钟定义,与服务内其它所有截止期同源;时钟回拨拉长窗口是该定义的一致行为;驱逐任务只是卫生措施,读侧截止判定才是权威,因此驱逐不失效不会提供过期答案;缓存项按 execution id 有界)。

本轮声明为"不要求处理"的延后项(v3 窗口传递见证、shutdownNow 见证、harness 构造器清理)保持原样。

验证(合并树 + 本轮):runtime-broker clean 全量 620 测试,唯一失败为基线同现的存量 flake;managed-agent-server 邻近 17/17 绿;R2-2 与 R2-5 的钉住测试均在变异下变红、修复后变绿。

wenshao added 2 commits October 3, 2026 13:51
An undirected and an adversarial pass over the merged branch head found
one Critical and ten Suggestions. Fixed here:

- The SIGTERM mirror test destroyed the fake worker without reading its
  ready line first. The fake installs its handler in top-level script
  flow and writes the ready line from the listen callback, so a destroy
  issued straight after spawn lands before any handler exists and the
  worker dies through SIGTERM's default disposition: the assertion
  passed whether or not the default handler worked. It now waits for the
  ready line. Wedging the default arm keeps the fixed test red while the
  old body stayed green three runs out of three.
- The v3 result-poll backoff cap drops from 5s to 2s, since the cap only
  bounds how late a finished result is picked up, and the polling test
  now pins the first capped gap into [1.9s, 2.9s) so the cap cannot
  drift back silently (an uncapped doubling schedules 3.2s there).
- The pass-budget reclaim test raced its own deadline: a 3s lease gives a
  12s operation deadline inside the test's 60s wait, so a slow runner
  could answer on the deadline instead of on the 16-pass budget and the
  assertion would still see runtime_broker_runtime_lost. Both reclaiming
  services now take a 10s lease (40s deadline) through a service()
  overload, and the drain comment states the traced arithmetic: the
  first site releases nothing before the loss evidence exists, the next
  two drain 16 * 100 rows each, and the ~1.6k that remain stop the
  reclaim.
- The deployment README documents the two knobs this branch added
  (allow-non-loopback and the v3 result window) with their defaults.
- The hardening design doc, in both languages, no longer claims a
  stalled renewal blocks "at most one pool thread" (a two-thread pool
  saturates at two), states what the split actually buys, and describes
  the cooldown replay rule the code implements: a cached record that is
  no longer UNKNOWN is replayed whole, otherwise the higher version of
  the two wins.

Declined: the reported service leak on server close cannot happen
because the server's close already cascades to the service; relaxing the
in-memory release gate was tried and reverted, since it is the sibling
of the JDBC backend's same-DataSource precondition rather than drift
from it (alignment stays in #13204).

Suites green on top: runtime-broker 627 tests / 0 failures / 2 skipped
with checkstyle and spotbugs clean, managed-agent-server fix-adjacent
19/19 against the reinstalled jar.
@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Self-audit round 1 (undirected + adversarial) — dispositions

Ran an undirected pass and an adversarial pass over the branch head after re-merging main (merge 51c1841). Result: 1 Critical + 10 Suggestions. All are fixed in af6691c except three declined below with rationale. Each behavioural fix is pinned by a test that was proven red against a mutant.

Fixed

C1 — FakeAttestationWorkerTest.defaultWorkerExitsOnSigterm proved nothing. It called destroy() without first reading the worker's ready line. The fake installs its SIGTERM handler in top-level script flow and only writes the ready line from the listen callback, i.e. on a later event-loop turn, so a destroy() issued straight after spawn lands before any handler exists and the worker dies through SIGTERM's default disposition — the test passed whether or not the default handler worked. It now waits for the ready line before destroying.

Mutation proof: with the fake's default arm wedged (process.on('SIGTERM', () => {}) unconditionally), the pre-fix body passed 3/3 runs (~0.28 s each); the fixed body fails with a default worker must exit on SIGTERM ==> expected: <true> but was: <false>.

S3 — v3 result-poll cap 5 s → 2 s. The cap only exists to bound how late a finished result is picked up, and 5 s is coarser than the polling it replaces. v3ResultPollingBacksOff now waits for seven polls and pins the first capped gap into [1.9 s, 2.9 s) plus a ≥ 1.5 s fifth gap, so both the doubling and the cap are bounded; 2.9 s stays under the 3.2 s an uncapped doubling would schedule, with enough slack for scheduler jitter.

Mutation proof: restoring Math.min(5_000, …) at that site fails with the backoff must stop at the 2s cap: 3321145416.

S4 + Adv2 — the pass-budget test raced its own deadline. reclaimBeyondThePassBudgetAnswersLost built both reclaiming services with a 3 s lease, so the measured operation deadline (4 × lease = 12 s) sat well inside the test's 60 s get(). On a slow runner the reclaim could answer on the deadline rather than on the 16-pass budget and the assertion would still see runtime_broker_runtime_lost — the budget would be untested. Both services now take a 10 s lease (40 s deadline) through a new service(...) overload.

The comment also miscounted the drain: it claimed three drain calls release 16 bounded passes each. Traced behaviour is that the first site returns before releasing anything (the loss evidence is not written yet), sites two and three drain 16 × 100 rows each, and the ~1.6k rows that remain stop the reclaim with LOST. The comment now says that. Side effect: the test went from ~23 s to ~2.2 s, because it no longer waits for a deadline to lapse.

S7 — the deployment README documents the two new knobs (QWEN_MANAGED_AGENT_RUNTIME_BROKER_ALLOW_NON_LOOPBACK, QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW) with their defaults and what each changes, next to the existing Broker environment list.

S2 + Adv3 — design doc corrected, both languages. The renewal-pool paragraph claimed "one stalled renewal blocks at most one pool thread"; with a two-thread pool, two parked ticks saturate it. What the split actually buys is that coordination work no longer queues behind a parked tick and that close() interrupts both pools without awaiting one — the text now says exactly that. The same paragraph still described a 5 s poll cap, and described the cooldown as replaying a cached lookup whose record "already settled", where the code replays a cached record that is no longer UNKNOWN and otherwise serves whichever of the two records carries the higher version.

Declined

S5 — "the service outlives the HTTP server": false positive. RuntimeBrokerHttpServer.close() stops the server, shuts its executor down and then calls service.close(), so the reported path cannot leave the service running.

S6 — relaxing the in-memory release gate: declined after trying it. InMemoryRuntimeBindingRepository.beginSessionRelease refuses a session/execution repository pair that is not the matching in-memory type. I removed the gate and pinned a pass-through wrapper in RuntimeRecoveryContract, and the JDBC half of the contract failed immediately with Release requires the same DataSource — JdbcRuntimeBindingRepository.beginSessionRelease enforces the same precondition at both of its entry points, because the single-transaction guarantee only holds when both repositories reach the same store. The in-memory check is the sibling of that one, not drift from it, and accepting a mismatched pairing silently would weaken exactly the release decision this fix rests on. Per-backend alignment is already tracked in #13204. Both edits were reverted; the contract is unchanged.

Adv-S1 — injecting a clock into the HTTP fixture: declined. The cooldown assertions hold with roughly an order of magnitude of wall-clock margin, and that fixture already sleeps for the same purpose elsewhere; a clock seam there costs more than it buys.

Verification

  • packages/sdk-java/runtime-broker: mvn clean test → 627 tests, 0 failures, 0 errors, 2 skipped; mvn checkstyle:check and mvn spotbugs:check clean.
  • packages/sdk-java/managed-agent-server (fix-adjacent): EmbeddedRuntimeBrokerTest + ManagedAgentPropertiesTest → 19/19 green against the freshly installed broker jar.
  • Mutation proofs above were run against the same tree and reverted from cp backups; git diff confirms no mutant remains.

@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Real-stack verification — head af6691ce1f

Verdict: the fixes hold on a real stack. Fix one thing before merging: bot R3-1 (still open) reproduces end to end. A release retry over a Session row that is already RELEASING finishes the release over a live execution. Base refuses that retry. The 9-line fix the bot suggested, plus a regression test, closes it without breaking anything else I measured (details below). Everything else is non-blocking.

Head was unchanged for the whole run. main moved to 1a4de7486a (#13206, web-shell only, no sdk-java change). A trial merge is clean. CI is green apart from review-pr, which is still running.

Setup

  • Arms: head af6691ce1f, base 5130c1a734 (the merge-base), and cand (head + the R3-1 fix). Each arm was built in its own Maven repo, and javap confirmed each arm's bytecode.
  • Database: MySQL 8.4.7 (InnoDB, REPEATABLE-READ, UTC). No H2 anywhere.
  • Broker processes: each Broker is its own JVM running the production RuntimeBrokerService, JDBC repositories and HTTP face. Workers are the bundled node dist/cli.js managed-runtime-worker; this PR changes no TS, so dist was built from the merge-base.
  • Spring: the real managed-agent-server jar, configured only through the documented env names.
  • TS client: the shipped Hosted Harness client (hosted-workspace-broker.ts) is driven directly.
PR claim Real-stack result
Release and admission are decided in one transaction across processes Two Broker JVMs on one MySQL, release and admission fired together each round (300 rounds). Rounds ending with the Session RELEASED while an execution was still active: base 70/300, head 0, cand 0. With +5 ms per DB hop: base 89/200, head 0/200. On head every round has exactly one winner: 409 runtime_session_busy or runtime_admission_closed. ✅
One stalled renewal no longer fences unrelated bindings One execution's dispatch renewal parked in a real InnoDB lock wait (45 s FOR UPDATE on its primary key), production lease 30 s. A healthy execution on another binding: base → UNKNOWN (it ran, but its result was thrown away); head → SETTLED/success. ✅
Non-loopback listen addresses are refused unless opted in Spring startup matrix. Head fails startup with an actionable message for 0.0.0.0, the LAN IP and an unresolved name, and starts with ALLOW_NON_LOOPBACK=true. localhost and ::1 still start. Base listened on *: and on the LAN IP. ✅
v3-result-window is configurable, and a suffix-less value is refused 500 → startup fails (must be at least PT1S); abc → fails; 1s and 45m → start. ✅
Wedged worker escalates to destroyForcibly; the exit hook covers workers still starting Spring SIGTERM with the worker frozen (SIGSTOP): head kills it after 5.1 s; on base it outlives the Broker. Library-embedded JVM SIGTERM (no close()): head kills workers in ≈0.1 s, both healthy ones and ones still in the ready handshake. Base orphans them: across all rig runs, 14 base workers were left with ppid 1, 0 on head. ✅
LOST reclaim drains past 300 sessions Simulated power loss with 303 Sessions (durable store + trusted reboot recovery). Base: first warm → 503 runtime_broker_runtime_lost with 300 released, the second warm recovers. Head: one warm → 200, all 303 released. ✅
UNKNOWN observation cooldown The cooldown is correct where it applies, but it never applies to the shipped client (F2). ⚠️

Test suites:

  • runtime-broker mvn verify: 627 tests, 0 failures (2 skipped); SpotBugs 0; Checkstyle 0.
  • mysql-integration failsafe profile (not run in the PR, run here against MySQL 8.4.7): 7/7, including the new verifyBeginSessionRelease contract.
  • managed-agent-server EmbeddedRuntimeBrokerTest + ManagedAgentPropertiesTest: 19/19.

race

F1 — bot R3-1 confirmed: a release retry over a RELEASING row ignores a live execution (fix before merge)

Setup: a real worker over the HTTP face, a Session row seeded as RELEASING with a PREPARED execution on the same binding and generation. That is what a pre-fix peer leaves behind when its unguarded check-then-act lets an admission in (card 1) and its worker release then fails, e.g. during a mixed-version rolling deploy.

arm POST :release Session after execution after worker told to release
base 409 runtime_session_busy RELEASING PREPARED 0
head 200 released=true RELEASED PREPARED 1
cand 409 runtime_session_busy RELEASING PREPARED 0

Head's transitionSessionToReleasing short-circuits on RELEASING without asking the execution repository. The candidate adds that check inside the short-circuit; it is the bot's suggestion (candidate-r31.patch, 9 lines plus one test). With the candidate:

  • A RELEASING row with nothing active still releases (200), so the idempotent retry is kept.
  • The new test releaseRetryOverAPersistedReleasingRowRefusesALiveExecution fails without the 9 lines (Expected CompletionException … nothing was thrown) and passes with them.
  • The full module suite runs 628 tests with 0 failures; SpotBugs 0, Checkstyle 0.
  • The 300-round race still has 0 contradictions.

r31

F2 — the cooldown does not reach the shipped poller (non-blocking, bot R3-3)

The only shipped path that polls an UNKNOWN execution repeatedly is HostedWorkspaceBroker.execute(…, waitForUnknown = mcp !== undefined). It sends GET …?reconcile=true every 250 ms, and reconcile=true bypasses the cooldown by design.

I made an execution UNKNOWN for real (the worker's /execute response is dropped while the shell keeps running) and drove the real client for 20 s:

client path head (GETs / worker status calls) base (GETs / worker status calls)
MCP session (?reconcile=true) 64 / 29 61 / 28
non-MCP session 1 GET, then 409 runtime_broker_execution_unknown same
20 rapid plain GETs on a v2 UNKNOWN 0 status calls 0

At ≈1.4 status calls/s over the 630 s MCP window, that is still about 900 status calls per execution on both arms — the #13183 symptom. The v3 and provider plain-GET path the cooldown does cover is pinned only by unit tests here; the shipped v3 client stops at its first UNKNOWN. Options: apply a (shorter) cooldown to reconcile=true as well, or have the client back off. Otherwise, narrow the PR text so it doesn't claim the #13183 fan-out is fixed.

F3 — the two-thread renewal pool absorbs exactly one parked renewal (non-blocking, as the design doc says)

With two parked renewals on two bindings, head's healthy execution also ended UNKNOWN, the same as base. The pool helps against one stuck row; it does not help against a database-wide stall, where every renewal's JDBC call parks together.

Rig deviation: the worker request timeout was 120 s (production 30 s), so a v2 call can outlive one lease. It stands in for long v3 executions, whose dispatch claims are renewed the same way.

stall

posture

lifecycle

Notes (no action needed)

  • N1: the shutdown escalation and the exit hook apply only to non-durable provisioners. Since feat(managed-agent): default durable local-process and trusted reboot recovery on #13211, Spring defaults to durable local-process (Linux), where workers are adopted on restart, so these paths are inert in the default deployment. A SIGKILLed Broker cannot run any hook on either arm, and the worker survives.
  • N2: the drain loop needs stop evidence. After a plain worker crash under the non-durable provisioner, the generation stays LOST with JOURNAL_LOST on both arms; that is the pre-existing writer-domain rule.
  • N3: V3_RESULT_WINDOW=45m is accepted, but the TS client's own v3 deadline is fixed at 30 min (bot R3-4).
  • N4: HOST=127.0.0.2 fails on macOS with a BindException on both arms because lo0 has no such alias. Head's loopback check accepts it.

Evidence: assets-pr13214@e8f18ecf. It holds the harness (RigBroker/RigAdmitter JVM mains over the module's test-jar, the drivers, the Spring matrix and lifecycle scripts), the raw results, and the candidate patch.

中文版

真实环境验证 — head af6691ce1f

结论:各项修复在真实栈上成立。合并前先修一处:bot R3-1(仍未解决)端到端复现。 对已处于 RELEASING 的 Session 行重试释放,会在 execution 仍活跃的情况下完成释放,而 base 会拒绝这次重试。bot 建议的 9 行修复加一个回归测试即可关闭该问题,且实测中其他行为都不受影响(见下)。其余发现均不阻塞合并。

验证全程 head 未变。main 已前进到 1a4de7486a(#13206,只改 web-shell,不涉及 sdk-java),试合并干净。CI 除仍在运行的 review-pr 外全绿。

环境

  • 三臂: head af6691ce1f、base 5130c1a734(merge-base)、cand(head + R3-1 修复)。每臂用独立 Maven 仓库构建,并用 javap 核对了字节码。
  • 数据库: MySQL 8.4.7(InnoDB,REPEATABLE-READ,UTC),全程未用 H2。
  • Broker 进程: 每个 Broker 是独立 JVM,跑生产 RuntimeBrokerService、JDBC 仓库和 HTTP 面。worker 是打包的 node dist/cli.js managed-runtime-worker;本 PR 不改 TS,dist 取自 merge-base 构建。
  • Spring: 真实 managed-agent-server jar,只通过文档列出的环境变量配置。
  • TS 客户端: 直接驱动随产品发布的 Hosted Harness 客户端(hosted-workspace-broker.ts)。
PR 主张 真实栈结果
跨进程的释放与准入在同一事务内判定 两个 Broker JVM 共享一个 MySQL,每轮同时发起释放和准入(300 轮)。结束时 Session 为 RELEASED 而 execution 仍活跃的轮数:base 70/300,head 0,cand 0。每跳 +5 ms 延迟时:base 89/200,head 0/200。head 每轮都恰好只有一方胜出:409 runtime_session_busy 或 runtime_admission_closed。 ✅
一个卡住的续约不再围栏无关 binding 让一个 execution 的派发续约卡在真实 InnoDB 锁等待中(对其主键 FOR UPDATE 45 s),生产租约 30 s。另一个 binding 上的健康 execution:base → UNKNOWN(命令跑完了,结果被丢弃);head → SETTLED/success。 ✅
默认拒绝非回环监听地址,需显式开启 Spring 启动矩阵:head 对 0.0.0.0、局域网 IP 和无法解析的主机名都启动失败并给出可操作的提示;设 ALLOW_NON_LOOPBACK=true 后可启动;localhost、::1 照常启动。base 则实际监听了 *: 和局域网 IP。 ✅
v3-result-window 可配置,无单位的值被拒绝 500 → 启动失败(must be at least PT1S);abc → 失败;1s 和 45m → 可启动。 ✅
卡死的 worker 升级为 destroyForcibly,退出钩子覆盖仍在启动的 worker Spring 收到 SIGTERM 时 worker 已被冻结(SIGSTOP):head 在 5.1 s 后杀掉它;base 上它比 Broker 活得更久。 嵌入式库 JVM 收到 SIGTERM(未调用 close()):无论健康的 worker 还是仍在 ready 握手的 worker,head 都在约 0.1 s 内杀掉;base 则留下孤儿:整个装置跑完后 base 留下 14 个 ppid 为 1 的 worker,head 为 0。 ✅
LOST 回收能清空超过 300 个 session 303 个 Session 模拟断电(durable store + 受信重启恢复)。base:第一次 warm 返回 503 runtime_broker_runtime_lost,释放了 300 个,第二次 warm 才恢复。head:一次 warm 返回 200,303 个全部释放。 ✅
UNKNOWN 观测冷却 在它生效的路径上是正确的,但随产品发布的客户端从不走这条路径(F2)。 ⚠️

测试套件:

  • runtime-broker mvn verify: 627 个测试,0 失败(2 个跳过);SpotBugs 0;Checkstyle 0。
  • mysql-integration failsafe profile(PR 中未跑,这里对 MySQL 8.4.7 跑):7/7,包含新增的 verifyBeginSessionRelease 契约。
  • managed-agent-server EmbeddedRuntimeBrokerTest + ManagedAgentPropertiesTest: 19/19。

F1 — bot R3-1 成立:对 RELEASING 行重试释放会忽略活跃 execution(建议合并前修)

装置:真实 worker 经 HTTP 面,预置一个 RELEASING 的 Session 行,同一 binding 和代际上有一个 PREPARED 的 execution。这正是修复前的 peer 留下的状态:它未加保护的先查后改放进了一次准入(图 1),随后其 worker release 又失败,例如在新旧版本混跑的滚动发布中。

臂 POST :release Session 之后 execution 之后 worker 收到 release
base 409 runtime_session_busy RELEASING PREPARED 0
head 200 released=true RELEASED PREPARED 1
cand 409 runtime_session_busy RELEASING PREPARED 0

head 的 transitionSessionToReleasing 遇到 RELEASING 直接短路返回,不查 execution 仓库。候选补丁在短路分支里补上这项检查,即 bot 的建议写法(candidate-r31.patch,9 行加一个测试)。应用候选补丁后:

  • 没有活跃 execution 的 RELEASING 行重试仍返回 200,幂等重试得以保留。
  • 新测试 releaseRetryOverAPersistedReleasingRowRefusesALiveExecution 去掉这 9 行时失败(Expected CompletionException … nothing was thrown),加上后通过。
  • 整个模块套件 628 个测试 0 失败;SpotBugs 0,Checkstyle 0。
  • 300 轮竞态仍是 0 次矛盾。

F2 — 冷却覆盖不到随产品发布的轮询方(不阻塞,bot R3-3)

随产品发布的代码里,唯一会反复轮询 UNKNOWN execution 的路径是 HostedWorkspaceBroker.execute(…, waitForUnknown = mcp !== undefined)。它每 250 ms 发一次 GET …?reconcile=true,而 reconcile=true 按设计绕过冷却。

我真实地造出一个 UNKNOWN execution(丢弃 worker 的 /execute 应答,shell 继续运行),用真实客户端驱动 20 s:

客户端路径 head(GET 次数 / worker status 调用) base(GET 次数 / worker status 调用)
MCP 会话(?reconcile=true) 64 / 29 61 / 28
非 MCP 会话 1 次 GET,随后 409 runtime_broker_execution_unknown 相同
对 v2 UNKNOWN 连续 20 次普通 GET 0 次 status 调用 0

按约 1.4 次 status/s 计,在 630 s 的 MCP 观测窗口内每个 execution 仍有约 900 次 status 调用,两臂相同,即 #13183 描述的症状。冷却真正覆盖的 v3/provider 普通 GET 路径,在这里只有单测钉住;而随产品发布的 v3 客户端遇到第一次 UNKNOWN 就停止。可选做法:给 reconcile=true 也加一个(更短的)冷却,或让客户端退避;否则请收窄 PR 文字,不要声称已修复 #13183 的穿透问题。

F3 — 双线程续约池恰好只能吸收一个卡住的续约(不阻塞,与设计文档一致)

两个 binding 上各有一个卡住的续约时,head 上的健康 execution 同样变为 UNKNOWN,表现与 base 相同。线程池能对付单行卡住,对整库停顿无效,因为那时每个续约的 JDBC 调用会同时卡住。

装置偏差:worker 请求超时设为 120 s(生产为 30 s),让 v2 调用能跨过一个租约周期,用来代表长时间运行的 v3 execution,后者的派发租约以同样方式续约。

备注(无需处理)

  • N1: 关停升级和退出钩子只作用于非 durable provisioner。自 feat(managed-agent): default durable local-process and trusted reboot recovery on #13211 起,Spring 默认使用 durable local-process(Linux),worker 在重启后被接管,因此这些路径在默认部署下不生效。Broker 被 SIGKILL 时任何钩子都无法运行,两臂的 worker 都会存活。
  • N2: 清空循环需要停止证据。非 durable provisioner 下 worker 单纯崩溃后,两臂的代际都停在 LOST(JOURNAL_LOST),这是既有的写者域规则。
  • N3: V3_RESULT_WINDOW=45m 会被接受,但 TS 客户端自己的 v3 期限固定为 30 分钟(bot R3-4)。
  • N4: HOST=127.0.0.2 在 macOS 上两臂都因 lo0 没有该别名而 BindException;head 的回环检查本身接受该地址。

证据:assets-pr13214@e8f18ecf,包含装置(基于模块 test-jar 的 RigBroker/RigAdmitter JVM 入口、各驱动脚本、Spring 启动矩阵与生命周期脚本)、原始结果和候选补丁。

An adversarial pass over the tests and an undirected pass over the whole
diff found no Critical and fifteen Suggestions; thirteen are fixed here
and two are declined in the PR thread.

The headline is that the cross-process stress never exercised half of its
own matrix: instrumenting it shows admission won 0 of 200 rounds, because
the release goes straight to the session row lock while admission first
takes the placement-domain lock and does two reads. Half the rounds now
hold the release until the admission has committed, and the test asserts
both directions occurred, so dropping the FOR UPDATE that the whole fix
rests on turns it red with two contradictions instead of passing.

Three more assertions were satisfiable without the code they name:

- The settled-cache replay branch could be deleted without failing its
  test, because the version comparison picked the same record; the stale
  snapshot is now pinned to the settled record's own version.
- The "serve the fresher of two UNKNOWN records" rule was only covered
  with the cache holding the newer record, so collapsing that comparison
  survived the whole suite; a cancel now lands after the cached lookup
  too.
- Deleting the renewal pool's shutdown in close() was invisible, since a
  tick cancels itself while a pool's threads exit only on shutdown; a new
  test tracks the thread IDs an in-flight dispatch starts and asserts
  they are gone after close.

The v3 poll cap moves off the wall clock: the delay computation is
extracted and asserted exactly for attempts 0-7, which tells a 2s cap
from a 2.5s one in 0.2s and drops a 900ms scheduler-jitter budget from
the timing test.

Three tests that cannot fail on Windows now say so, since destroy()
terminates outright there and --ignore-term cannot wedge a worker. The
exit-hook test no longer inherits its observation window from a sleep
inside the forked harness, and the provision-race test's javadoc claims
only the guard it can reach: spawn-and-register atomicity is structural,
inside the lifecycle lock, not observable from outside it. A refused
release is now retried to prove it left the path usable, and the embedded
broker's window validation covers its null and millisecond arms.

Documentation that described behaviour the code does not have: the
deployment README called v3-result-window a result-retention period when
it is the post-dispatch polling deadline whose expiry marks the execution
UNKNOWN; the new repository primitive's contract omitted the
runtime_session_not_ready outcome both implementations throw; the window
floor's two comments justified 1s by a poll schedule that would support
100ms; the HTTP layer claimed a mutation response describes post-mutation
truth, which the pre-existing in-flight lookup merge does not guarantee;
and the design doc's problem section counted three defects while its
decisions listed five.

Declined: a longer lease for the pass-budget test (its drain measures 2.2s
against a 40s deadline, not the 12s the suggestion inferred from a
comment), and rescheduling the one-shot cooldown eviction (a backwards
clock jump retains one small record per affected execution, on the
automatic-observe path only; rescheduling becomes a perpetual timer per
entry under the injected clocks those tests use).

Suites green on top: runtime-broker 630 tests / 0 failures / 2 skipped
with checkstyle and spotbugs clean, managed-agent-server fix-adjacent
19/19 against the reinstalled jar.
@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Self-audit round 2 (adversarial + undirected) — dispositions

Two independent passes over af6691c: an adversarial one attacking the tests and the concurrency claims, and an undirected one over the whole diff. Neither found a Critical. Together they returned 15 Suggestions: 13 fixed in 20d8f48, 2 declined with measurements below. Every new or strengthened assertion was proven red against a mutant.

The headline: the core race test was one-sided

concurrentCrossProcessAdmitAndReleaseNeverContradict counted contradictions but never counted which side won. Instrumenting it showed admission won 0 of 200 rounds: the release reaches the session row first every time, because its path goes straight to the row lock while admission first takes the placement-domain lock and does two reads. So the stress never exercised the runtime_session_busy direction at all — only runtime_admission_closed, 200 times.

Fix: half the rounds now hold the release until the admission has committed (a second latch), half stay a tight race, and the test asserts both counters are non-zero, so a one-sided schedule fails loudly instead of passing quietly.

Mutation proof, and the reason this mattered: changing beginSessionRelease's selectSession(…, true) to false — dropping the FOR UPDATE that is the whole fix — now fails with admission and release contradicted each other ==> expected: <0> but was: <2>. admitExecution never updates the session row, so the version CAS cannot catch a racing admission; the row lock is the only cross-process guard, and it is now pinned from both directions.

Fixed — tests

  • Cooldown replay, both arms pinned. cooledObservationNeverPairsASettledAnswerWithAStaleRecord was satisfied by the version comparison alone — deleting the "replay a settled cache whole" branch left it green. The stale snapshot is now pinned to the settled record's own version, so only that branch can keep a terminal answer off a stale record. Proof: deleting the branch fails with a settled answer must never ride a stale UNKNOWN record ==> expected: <SETTLED> but was: <UNKNOWN>.
  • The fresher-record rule's other arm had no test. Every existing case had the cache holding the newer record, so freshest = cached.getRecord() survived the whole suite. New cooledObservationServesACancelThatLandsAfterTheCachedLookup cancels after the cached lookup and asserts the caller's re-read wins while the cached worker answer is still the answer. Proof: that mutant now fails the new test (a cancel after the cached lookup must stay visible) and still passes the old one — exactly the reported gap.
  • close() had an unpinned pool shutdown. Deleting renewalScheduler.shutdownNow() left the entire suite green: a renewal tick cancels itself once the service is closed, but a pool's threads exit only on shutdown, so every rebuilt service leaked two daemon threads. New closeStopsTheRenewalPool records the renewal threads that appear while a dispatch is in flight and asserts those exact thread IDs are gone after close(). Proof: deleting the shutdown fails it. The coordination pool's shutdown is pre-existing code and no flow in these fixtures starts that thread, so it stays unpinned.
  • The v3 poll cap is pinned deterministically. The wall-clock assertion could not tell a 2 s cap from a 2.5 s one and needed a 900 ms scheduler-jitter budget on a loaded runner. The computation is extracted to v3PollDelayMillis(attempt) and asserted exactly for attempts 0–7 (100, 200, 400, 800, 1600, 2000, 2000, 2000); the wall-clock test goes back to four polls and keeps only bounds a scheduler cannot fire early. Proof: caps of 5 s and 2.5 s both fail in 0.2 s.
  • Windows-vacuous tests are skipped there. destroy() terminates outright on Windows, so --ignore-term cannot wedge a worker: the two escalation tests and round 1's SIGTERM mirror test passed without exercising anything, while the mirror's premise test was already @DisabledOnOs(WINDOWS). All three now carry the same annotation, with the reason in the javadoc.
  • The exit-hook test's observation window is test-controlled. The forked harness used to sleep(2000) and exit, so the window in which the worker is visible belonged to the harness: a polling thread preempted for 2 s reported "the harness never spawned a worker". The harness now stays in the handshake (bounded at 60 s so an orphaned harness cannot linger) and the test ends the JVM with destroy() once it has seen the worker — SIGTERM runs the same shutdown hooks. The javadoc also records that this path covers the hook's destroy(), not the forcible fallback, because the forked worker obeys SIGTERM.
  • provisionDuringCloseNeverOrphansAWorker's claim narrowed to what it proves. Its leverage is the 5 s forcible-destroy grace, which widens the terminated-guard window, not the nanosecond-wide spawn-and-register window; moving starting.add(…) outside the lifecycle lock survives it. The javadoc now says the test pins the guard and the absence of lingering children, and that spawn-and-register atomicity is structural — both happen inside the lock terminateAll() also holds — rather than observable from outside it.
  • A refused release must leave the path usable. serviceReleaseRejectsARacingCrossProcessAdmission retries the release and asserts the same 409, so a half-set release slot cannot hide behind the first failure.
  • The embedded-broker window validation covers its whole branch. refusesAV3ResultWindowBelowThePollFloor (renamed — "before anything is built" was never asserted) walks null, ZERO, -1ms and 999ms; the null arm and the realistic suffix-less-milliseconds case were untested.

Fixed — documentation that described behaviour the code does not have

  • The README described v3-result-window as a retention period. It said "how long a finished v3 result stays retrievable". The value has exactly one read site: it is the deadline passed to the post-dispatch result poll, and when it lapses the execution is marked UNKNOWN (which is what v3ResultWindowBoundsThePolling pins). An operator reading "retention" and setting 5s would silently degrade every tool call longer than 5 s to UNKNOWN. The entry now says it is a polling deadline and names that consequence; the design doc, in both languages, says the same and states the 30 m default.
  • The new repository primitive's contract omitted one of its four outcomes. beginSessionRelease's javadoc listed null / already-releasing / runtime_session_busy, but both implementations also throw runtime_session_not_ready for any other state — the case the contract test's "a terminal session is refused" branch covers. A caller reading the contract would expect a FAILED session to come back as a null CAS retry.
  • MIN_V3_RESULT_WINDOW's stated reason was wrong. Both the constant's javadoc ("the smallest window that can complete a second poll") and the constructor comment ("below the first poll tick the window cannot complete even one retry") justify 1 s by the poll schedule, but the first retry is scheduled 100 ms out, so that reasoning would support a ~100 ms floor. The real reason — a suffix-less duration binds as milliseconds, so "30" meant as 30 minutes arrives as 30 ms — is now the only reason given.
  • The HTTP layer claimed a guarantee it does not make. The comment said a mutation's own response "must describe the post-mutation truth". :start/:cancel do bypass the cooldown cache, but they can still join an in-flight lookup that a polling GET started before the mutation (the pre-existing putIfAbsent merge), so the response can describe the pre-mutation state. The comment now claims only what the code does: a mutation response never serves the cooldown cache.
  • The design doc's Problem section said "three high-risk defects" while Decisions listed five entries. Both languages now name the two medium findings the change also fixes (bounded-force worker shutdown, and the LOST reclaim's pass budget), so the two sections agree.

Declined

  • A longer lease for the pass-budget test. The suggestion inferred from round 1's comment that the drain needs more than 12 s, leaving the 40 s deadline under 3.3× margin. Measured, the budget path answers in 2.2 s; the ~23 s seen before round 1 was the reclaim waiting for a 12 s deadline to lapse, twice — which is exactly why the lease was raised. 40 s against 2.2 s is an 18× margin, and a longer lease would not change what the test asserts: its oracle is 0 < remaining < 4801 plus the runtime_broker_runtime_lost code, neither of which depends on the lease. The comment now states the measured numbers instead of implying a 12 s workload.
  • Re-scheduling the cooldown eviction task. The task is one-shot and gated on the service clock, so a clock that jumps backwards leaves that entry in the map: until the clock passes the entry's deadline again the read side treats it as fresh, and after that the entry is simply retained — one small record per affected execution — because no second task ever runs. Both effects are bounded by the size of the jump and confined to the automatic-observation path: an explicit reconcile=true never reads the cache and a mutation response never serves it. The alternatives are worse — re-scheduling on a miss becomes a perpetual 1 Hz timer per entry under the injected clocks the cooldown tests use, and clearing the map in close() only frees memory the service's own death already reclaims.

Verification

  • packages/sdk-java/runtime-broker: mvn clean test → 630 tests, 0 failures, 0 errors, 2 skipped; mvn checkstyle:check and mvn spotbugs:check clean.
  • packages/sdk-java/managed-agent-server (fix-adjacent): EmbeddedRuntimeBrokerTest + ManagedAgentPropertiesTest → 19/19 green against the reinstalled jar.
  • Five mutation proofs (row lock, settled-cache branch, freshest-record ternary, renewal-pool shutdown, poll cap ×2) were run against this tree and reverted from cp backups; the production diff is the v3PollDelayMillis extraction plus comment and javadoc corrections.

…ce window

Round 2 broke windows-latest / Java 21 (612 tests, 2 failures, with ubuntu
and macOS green). Both are fixed here without waiving the platform:

- The exit-hook test had moved the decision of when the forked harness
  exits from a sleep inside it to harness.destroy() in the test. destroy()
  is a SIGTERM only on POSIX; on Windows it is TerminateProcess, which runs
  no shutdown hooks, so the worker legitimately outlived the harness. The
  harness now polls a sentinel file and exits itself with System.exit(0)
  once the test has observed the worker: the window stays test-controlled
  and the hooks run on every platform.
- The provision-race test widens its race window with a --ignore-term
  worker, which cannot wedge anything on Windows, so close() finishes in
  milliseconds and the racing loop can miss the terminated guard. The guard
  assertion now runs only where destroy() is a signal; the
  platform-independent half - no lingering child processes - still runs
  everywhere. That code is unchanged from the previous Windows-green
  commit, so this was a timing flake rather than a new defect.

Also close a real orphan window in the release path. stop() removed the
worker from owned and then escalated to destroyForcibly on a daemon thread,
so for the whole five-second grace the process was tracked by neither set.
close() was covered, because shutting the executor down interrupts the
escalation and its handler destroys forcibly, but a bare JVM exit was not:
the daemon dies with the JVM and the hook's snapshot could not see the
worker. A worker that ignores SIGTERM, released and then hit by a Broker
SIGTERM inside five seconds, survived. The worker now moves from owned to
starting under the same lifecycle lock terminateAll() snapshots with, which
also removes the microsecond gap between those two set operations, and
leaves it when the escalation finishes. A forked test releases a wedged
worker and exits the harness JVM inside the grace window; without the
tracking it fails with that worker still alive.

Smaller corrections from the same audit round: the cross-process stress
rises to 600 rounds so its raced arm keeps the 200 rounds the odd/even
split had halved, and its comments now say what the win counters prove -
both arms ran - instead of claiming a contradictory interleaving was
reached; the v3 backoff test sweeps attempts 0-1000, pinning the inner
shift clamp (an unclamped shift wraps at attempt 57 and schedules zero and
negative delays, returning the polling to the rate the backoff exists to
remove); and the design doc's description of the pre-fix LOST reclaim is
corrected in both languages, since at the merge base it ran a single
bounded pass per phase and stranded larger generations rather than looping
without a budget. A test name and two assertion messages that still carried
rationales round 2 retracted in production are corrected, and the
exit-hook test's worker cleanup moves into its finally block so a failed
assertion cannot leave a spinning node process on the runner.

Declined: a production seam inside the release transaction to make the
row-lock pin deterministic. The raced arm already detects the missing
FOR UPDATE in three runs out of three, with three to five contradictions
each, and the transition's in-transaction re-check is pinned
deterministically by the delegating-repository release test.

Suites green: runtime-broker 631 tests / 0 failures / 2 skipped with
checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19
against the reinstalled jar.
@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Self-audit round 3 (adversarial + undirected) — dispositions

Two passes over 20d8f48: the adversarial one reported 1 Critical + 3 Suggestions, the undirected one 1 Critical + 7. The undirected Critical is real and CI confirms it — round 2 broke the Windows leg. The adversarial Critical's main claim does not survive measurement, but two of its sub-points are correct. Everything below is fixed in 56c2096 except three declined items.

Round 2 broke windows-latest / Java 21 — fixed without waiving the platform

gh pr checks on 20d8f48: 612 tests, 2 failures, both on Windows only (ubuntu Java 11/17/21 and macOS Java 21 green).

exitHookReclaimsAWorkerStillInStartup — a regression round 2 introduced. Round 2 moved the decision of when the forked harness exits from a sleep(2000) inside it to harness.destroy() in the test, on the reasoning that SIGTERM runs the same shutdown hooks. That is POSIX-only: on Windows Process.destroy() is TerminateProcess, which runs no hooks, so the worker legitimately outlived the harness and the assertion failed (worker 6864 survived the broker JVM exit mid-handshake) — the exact rationale round 2 wrote into three @DisabledOnOs(WINDOWS) annotations elsewhere in the same commit. Fixed by keeping both properties: the harness now polls a sentinel file and exits itself with System.exit(0) once the test has observed the worker, so the window is still test-controlled and the hooks still run on every platform. No Windows waiver.

provisionDuringCloseNeverOrphansAWorker — a pre-existing Windows flake. a provision racing the close must hit the closed guard ==> expected: not <null>. The test widens the race window with a --ignore-term worker, which cannot wedge anything on Windows, so close() finishes in milliseconds and the racing loop can miss the guard entirely. The code is identical to af6691c, whose Windows leg was green, so this is timing, not a new defect. Fixed by asserting the guard only where destroy() is a signal (DESTROY_IS_SIGTERM), keeping the platform-independent half — no lingering child processes — asserted everywhere.

A real orphan window in the release path (undirected S5) — fixed in production code

stop(lease) removed the worker from owned and then handed the forcible escalation to a daemon thread, so for the whole 5 s grace the process was in neither owned nor starting. close() was covered (its executor.shutdownNow() interrupts the escalation, whose handler destroys forcibly), but a bare JVM exit was not: the daemon dies with the JVM and the exit hook's snapshot could not see the worker. A released worker that ignores SIGTERM, plus a SIGTERM to the Broker inside 5 s, stranded it — the same class of gap the starting set was added to close for the ready handshake.

stop() now moves the worker from owned to starting under the same lifecycle lock terminateAll() snapshots with, and leaves it when the escalation finishes (or immediately in the rejected-execution fallback). Doing the move under that lock also removes the microsecond gap between the two set operations, during which the worker was in neither set — the same shape as the spawn-and-register window the lock already closed. New forked test exitHookReclaimsAWorkerInsideItsReleaseGraceWindow releases a wedged worker and exits the harness JVM immediately, inside the grace window. Proof: without the tracking, it fails with worker 20917 survived a JVM exit inside its release grace window (re-verified after the locking change: worker 25506 survived…).

The stress test's reported Critical — main claim refuted, two sub-points accepted

The adversarial pass predicted that dropping the FOR UPDATE from beginSessionRelease would leave the whole module green, because round 2's even rounds commit the admission before the release starts and its odd rounds are won by the release almost every time — making the new "both interleavings occurred" assertion satisfiable by the test's own latch ordering.

Measured on this tree the prediction does not hold: with selectSession(…, true) → false, concurrentCrossProcessAdmitAndReleaseNeverContradict failed 3 runs out of 3, with 5, 3 and 3 contradictions. The raced arm reaches the dangerous interleaving in roughly 1% of rounds. Two sub-points are correct and fixed:

  • The counters assertion proved less than its wording claimed. It shows both arms ran; it cannot show a contradictory interleaving was reached, since the even rounds guarantee admitWins > 0 by construction. The javadoc, latch comment and assertion message now say what the counters prove and name the raced arm as what detects a missing row lock.
  • Round 2 halved the raced rounds. The odd/even split cut the tight race from 200 rounds to 100. Rounds are now 600 — 300 raced, 300 sequential — above the original count, still ~1.9 s.

Declined: the suggested package-private hook inside the release transaction to make the pin deterministic. That is a test seam in core repository code for a property the raced arm already detects at ~97% per run, and the transition's in-transaction re-check is separately pinned deterministically by serviceReleaseRejectsARacingCrossProcessAdmission.

Fixed — smaller items

  • The backoff's inner shift clamp was unpinned (adversarial S1). v3PollDelayMillis is Math.min(2_000, 100L << Math.min(attempt, 6)); the round-2 test asserted attempts 0–7, where the outer cap hides the clamp, and a 30-minute window reaches attempt ~900. The test now sweeps 0–1000 and requires every delay inside [100 ms, 2 s]. Proof: 100L << attempt fails at backoff left its bounds at attempt 57: -4035225266123964416, with the other two v3 tests green.
  • A comment described a synthetic input as a production threat (adversarial S2). No repository can produce the pair the settled-cache pin injects — both backends refuse a CAS whose expectation is terminal and always bump the version — so the version comparison already prefers the cache for every reachable input and the branch is defence-in-depth. The comment now says that, and says the stamped version is synthetic; the branch and the pin stay.
  • A failed assertion could orphan the harness's worker on the runner (adversarial S3): the harness-exit assertion ran before the worker cleanup and the finally destroyed only the harness. The cleanup moved into the finally.
  • The design doc misdescribed the pre-fix LOST reclaim (undirected S2). Both languages said the old code "repeated its bounded 100-row passes with no budget", which is what an intermediate state of this branch did, not what main does: verified at the merge base, cleanupLost called recoverLost once per phase with no loop, so a generation larger than one pass stayed LOST and answered runtime_broker_runtime_lost on every later attempt. Corrected in English and Chinese.
  • A test still carried the rationale round 2 retracted in production (undirected S3): v3ResultWindowBelowTheFirstPollTickIsRefused is now v3ResultWindowBelowTheFloorIsRefused, with its javadoc restated in terms of the suffix-binds-as-milliseconds reason.
  • An assertion message repeated a guarantee round 2 removed from the production comment (undirected S4): RuntimeBrokerHttpServerTest now says "a mutation response must not serve the cooldown cache" rather than "must describe post-mutation truth".

Declined

  • The production seam for a deterministic row-lock pin — see above.
  • Undirected S6 (low confidence, explicitly asking for the experiment): same subject as the adversarial Critical; the experiment is reported above.
  • Undirected S7 (annotation style): the fully-qualified @org.junit.jupiter.api.condition.DisabledOnOs(…) in FakeAttestationWorkerTest matches the pre-existing annotation on its sibling ignoreTermSurvivesSigterm; rewriting both is unrelated churn.

Verification

  • packages/sdk-java/runtime-broker: mvn clean test → 631 tests, 0 failures, 0 errors, 2 skipped; mvn checkstyle:check and mvn spotbugs:check clean.
  • packages/sdk-java/managed-agent-server (fix-adjacent): 19/19 against the reinstalled jar.
  • Three mutation proofs this round (FOR UPDATE ×3 runs, shift clamp, release-grace tracking), all reverted from cp backups.
  • Windows cannot be exercised locally here (macOS). The two Windows fixes remove the platform dependency rather than waive it, so the windows-latest / Java 21 leg on this push is the real check; I will report its result.

Round 4 found no Critical: an adversarial pass over the tests and an
undirected pass over the whole diff returned eleven Suggestions between
them, all addressed here.

Two of the newest tests described a mechanism that is not what happens.
The release-grace harness claimed its exit kills the daemon escalation
thread, but System.exit runs the shutdown hooks first, so during the
hook's own grace wait that thread is alive and destroys the worker at
about the same moment; what the hook's snapshot actually provides is
keeping the JVM alive until the reclaim completes and issuing the destroy
itself. The test-side comment claimed its poll loop had to outlast that
grace window, when the loop only starts after the harness has exited and
halted, so what it waits out is pid reaping.

The guard assertion that only makes sense where destroy() is a signal
became its own test guarded by an assumption, so a platform that cannot
reach the guard reports skipped instead of passing without exercising it.
The shared race fixture moved into a helper, and the platform-independent
half - no child process outlives the provisioner - still runs everywhere.

Both forked tests cleaned up by killing a bare pid, which a recycled pid
would turn into killing an unrelated process. They now require that pid to
still name a node worker, and the liveness poll applies the same identity
check so a recycled pid cannot read as a false failure. The clock the
cooldown tests advance is volatile now, since the service reads it from
the HTTP handler and coordination threads while the test thread writes it.
The provisioner's close() also clears the tracking set, whose entry a
discarded escalation task would otherwise never remove.

The synchronized block in stop() cannot be pinned from outside the lock,
so its comment now says the guarantee is structural, the way the
spawn-and-register javadoc already does. The tracking set's field comment
names both of its membership windows instead of only the pre-issuance one.
The backoff sweep no longer ties its range to the default window, which
has a floor but no ceiling.

Documentation: the design docs still described a 200-round stress when the
code runs 600, and understated which outcomes the release primitive's
contract test covers; the deployment README's guidance for opting into a
non-loopback bind was weaker than the server's own refusal message, which
asks for a TLS-terminating layer that authorizes callers rather than a
restricted network, on a face that serves one global bearer token in
plaintext; and the release transaction gains a comment recording that its
statement order is load-bearing under InnoDB repeatable read, where a read
moved ahead of the locking read would silently reopen the race without H2
showing it.

Suites green: runtime-broker 632 tests / 0 failures / 2 skipped with
checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19
against the reinstalled jar.
@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Self-audit round 4 (adversarial + undirected) — dispositions

Two passes over 56c2096, including a line-by-line review of the only production change in that commit (LocalProcessRuntimeProvisioner.stop()). No Critical from either pass. Eleven Suggestions between them, all fixed in 4e91dc5. The findings are now at comment/doc/robustness level rather than behaviour level, which is the convergence signal this loop was looking for.

Both passes cleared the new production change

stop() moving the worker from owned to starting under lifecycle was traced through every interleaving the reports could construct: stop() against close(), against the exit hook, against start() for the same ownership key, and against a second stop() for the same lease. lifecycle is a leaf lock on all three paths that take it (start() spawns inside it, terminateAll() only snapshots inside it, stop() only touches two concurrent maps), the destroy and the 5 s wait both happen outside it, so neither a release nor JVM exit can stall on the other. The terminated check is correct in both directions: if stop() wins the lock the later snapshot sees the worker in starting; if terminateAll() wins, its snapshot already took the worker from owned; after close() returned, owned is empty so stop() finds nothing. No interleaving leaves a worker undestroyed, and OwnedProcess identity means a removal cannot evict a different worker.

Fixed — tests

  • Two comments described a mechanism that is not what happens. The release-grace harness said its exit kills the daemon escalation thread, "so the hook is the only thing left that can reclaim" the worker. System.exit runs shutdown hooks before halting, so during the hook's own 5 s grace the daemon is alive and fires destroyForcibly() within ~0.1 ms of the hook's. What the snapshot actually buys is keeping the JVM alive until the reclaim completes and issuing the destroy itself — which is still exactly what the fix provides, and the mutant confirms it (untracked, the JVM halts at ~T+0.2 ms, the daemon dies mid-wait, the worker survives). The test-side comment said its 20 s poll "only has to outlast" the hook's grace window; the poll starts after harness.waitFor returns, i.e. after the JVM halted, so it waits out pid reaping. Both reworded.
  • A platform that cannot reach an assertion reported a pass. provisionDuringCloseNeverOrphansAWorker's guard assertion was the only check on "Managed Runtime provisioner is closed." anywhere in either module, and making it conditional on DESTROY_IS_SIGTERM left the Windows leg green for a guard it never exercised (measured on macOS: deleting the guard turns both the new guard test and the orphan test red — 2 failures; on Windows the race cannot reach the guard at all, which is precisely why that half is now an assumption rather than a conditional assertion). It is now its own test, provisionAfterCloseHitsTheTerminatedGuard, using assumeTrue, so Windows reports skipped; the shared race fixture moved into a helper and the platform-independent orphan check still runs everywhere. (The os.name derivation itself was verified correct across Linux, macOS, Windows, WSL and Cygwin.)
  • Both forked tests killed a bare pid in their cleanup. A recycled pid would turn that into SIGKILLing an unrelated process on a shared host, and the same absence of an identity check made the liveness poll a false-red risk. Both now require the pid to still name a node process. The two sides deliberately differ when the command line cannot be read: the poll treats it as still alive (so an unreadable command line cannot produce a false green), the cleanup treats it as not ours (so it never kills a stranger).
  • The cooldown tests' clock had a data race. MutableClock.now was a plain field written by the test thread and read by the HTTP handler and the coordination scheduler when judging cooldown expiry — no happens-before edge, and the only nondeterminism in those two assertions. Now volatile.
  • The backoff sweep's range was tied to the default window, which has a floor but no ceiling, so a larger configured window reaches attempts the sweep did not cover. It now runs 0–100 000 (still microseconds) and the javadoc no longer justifies the range with the default.

Fixed — production, small

  • close() left an asymmetric cleanup: it cleared owned and shut the executor down, but a queued escalation task then never runs, and its finally was the only thing removing that worker from starting. No wrong behaviour (the process is already destroyed by terminateAll, and terminated blocks new entries), just a retained reference to a dead Process. close() now clears the set too.
  • The lifecycle block in stop() cannot be pinned from outside the lock, and the report confirmed the mutant (drop the monitor, keep both statements) leaves all 631 tests green because the harness releases before it exits. Its comment now says the guarantee is structural, mirroring the spawn-and-register javadoc, so nobody assumes it is test-pinned. It also records that the !terminated half is an equivalent mutant.
  • The starting field's invariant comment described only one of its two membership windows (pre-issuance handshake); it now names the post-release grace window as well, since that is where a future "tidy-up" would go wrong.

Fixed — documentation

  • The design docs still said "a 200-round cross-process stress" in their Validation section while the code runs 600 (300 raced). The same commit that raised the count edited these files and missed it. Both languages now state the split, and the release primitive's coverage is enumerated rather than called "four outcomes".
  • The README's opt-in guidance for a non-loopback bind was weaker than the code's own refusal message. The server says pass allowNonLoopback only behind a TLS-terminating layer that authorizes callers; the README said "that interface is already restricted to trusted peers". On a face that serves one global bearer token over plaintext HTTP with no per-tenant authorization, a network restriction alone still puts that token on the wire. The README now asks for the TLS-terminating, authorizing layer and says what a token leak costs.
  • The release transaction's statement order is load-bearing and was undocumented. Under InnoDB REPEATABLE READ the guarantee additionally requires that the locking session read stays the transaction's first statement and the plain execution read stays its first consistent read, so the read view is built after the row lock is held. Moving any read ahead of the lock would silently reopen the original race, and H2 (MODE=MySQL, RC semantics) would not show it. There is now a comment saying exactly that.

Also noted, no change

  • MutableClock's remaining uses and the 600-round stress were checked for deadlock and leaked rounds: the 2-thread pool gives each round two distinct threads by construction, admitDone.countDown() sits in a finally, and an admission that throws still trips the exact-error-code check. Measured cost at 600 rounds: 0.82 s.
  • The win-counter comment was tightened once more: admitWins > 0 is guaranteed by the even rounds' latch, so what the assertion really guards is that the raced half still produces committed releases.

Verification

  • packages/sdk-java/runtime-broker: mvn clean test → 632 tests, 0 failures, 0 errors, 2 skipped; mvn checkstyle:check and mvn spotbugs:check clean.
  • packages/sdk-java/managed-agent-server (fix-adjacent): 19/19 against the reinstalled jar.
  • One mutation proof: deleting the terminated guard in start() turns provisionAfterCloseHitsTheTerminatedGuard and provisionDuringCloseNeverOrphansAWorker red, so the split did not weaken the pin. Everything else this round is comments, documentation, test hygiene, or close() clearing a set no test can observe; the stop() tracking fix from the previous commit remains pinned by exitHookReclaimsAWorkerInsideItsReleaseGraceWindow.
  • windows-latest / Java 21 passed on 56c2096, confirming the two Windows fixes from the previous round; the new skipped-guard test will show as a skip there rather than a pass.

…ntry

Moving the no-active-execution check into beginSessionRelease left one path
without any check at all. A Session row already persisted as RELEASING
short-circuits transitionSessionToReleasing before the transaction runs, so
release() went straight to the worker and marked the row RELEASED over a
live execution, answering 200 released=true where the previous code
answered 409 runtime_session_busy.

This build cannot create such a row: every writer of RELEASING checks active
executions, admission needs READY under the same row lock, and RELEASING
never returns to READY. The rows that do exist come from before the guard -
a pre-fix peer's check-then-act admitted an execution and its worker release
then failed, or a mixed-version rolling deploy has an old peer committing
the transition while a new peer admits. That is the deployment model this
change's own design doc names, and the residue is the state the original bug
produced, so the release path is exactly where it has to be tolerated.
Completing such a release tells a worker to stop serving a session while a
tool is still running on it, and it pins the binding as well, since the
drain then refuses workspace close with workspace_close_execution_unsettled.

The re-entry now asks the execution repository before treating the retry as
idempotent, throwing the same 409 code and message the guarded transition
throws. Admission still needs READY under the row lock, so nothing new can
become active after this check: it closes a residue path, not a second race
window. A row with nothing active still releases, so the idempotent retry is
kept.

releaseRetryOverAPersistedReleasingRowRefusesALiveExecution seeds exactly
that row - RELEASING with a PREPARED execution on the same binding and
generation - and asserts the busy 409, that the row stays RELEASING, and
that the worker was never told to release; it then settles the execution and
asserts the same retry completes and the row reaches RELEASED. Removing the
check fails it.

The design doc is corrected in both languages where this change made it
wrong: the database round trip is not gone on the re-entry path, and the
tracking set now covers the post-release grace window as well as the ready
handshake. It also records that the observation cooldown cannot help the
shipped MCP poller, which asks with reconcile=true and bypasses the cache by
design, so the fan-out measured on that path is unchanged.

Suites green: runtime-broker 633 tests / 0 failures / 2 skipped with
checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19
against the reinstalled jar.
start() builds its closed-provisioner refusal as retryable, but the
managed-context catch below it retypes every RuntimeException into a
non-retryable 503 "Managed context startup failed; recovery is blocked." A
turn that merely raced a rolling restart therefore failed permanently
instead of being retried, and the message told an operator that recovery
was blocked when the provisioner had simply been closed. The refusal is now
rethrown unchanged, identified by instance rather than by message, and a
race test drives a managed-context provision into the guard and asserts
both the message and retryable=true; removing the rethrow fails it.

The embedded broker's listener failure now names its actual cause. The same
catch covers the token and port validation, so an over-long token used to be
reported as "Runtime Broker listener could not start" with the real reason
only in the cause chain.

The release contract gains the leg its busy assertion could not distinguish:
every fixture owned exactly one session on its own binding, so the
runtime_session_busy leg passed identically whether the predicate is
session-scoped or binding-scoped. A sibling session on the same binding now
releases while the busy one stays refused, on both backends. Widening that
predicate by one token would otherwise make every healthy session on a busy
binding unreleasable, with a non-retryable 409 telling the client not to try
again. The contract's javadoc also stops claiming it verifies atomicity:
every leg is single-threaded, and the atomicity evidence is the
cross-process race in Issue13183AdversarialTest.

The deployment README records that raising the v3 result window above 30m
buys nothing on the shipped path, because the TypeScript client stops
observing a v3 execution at its own fixed 30-minute deadline.

Suites green: runtime-broker 634 tests / 0 failures / 2 skipped with
checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19
against the reinstalled jar.
@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Review-thread dispositions — all 18 open threads answered

Every open thread now has a reply with its disposition and, where a fix landed, the commit and the mutation proof. Threads are left unresolved so the maintainer can close them in one pass.

Fixed — Critical

  • R3-1 (a release completing over a live execution on the RELEASING re-entry) → 445964f46e, with releaseRetryOverAPersistedReleasingRowRefusesALiveExecution. This was the one merge-blocking finding on the PR: it reproduced end to end in the three-arm rig, and it is a regression this branch introduced when the unconditional pre-check moved into the transition that the re-entry path never reaches.

Fixed — production

Fixed — tests

  • R2-6 and its fix-induced follow-up: the missing SIGTERM mirror arm was added, and then fixed for real — it destroyed the worker before reading the ready line, so SIGTERM's default disposition could kill it before any handler existed. It now waits for readiness and carries @DisabledOnOs(WINDOWS). Wedging the fake's default arm leaves the old body green 3/3 and turns the new one red.
  • R3-7 (POSIX-only tests with no OS gate): three @DisabledOnOs(WINDOWS) annotations, and the provision-race guard became its own assumeTrue-gated test so the Windows leg reports skipped instead of passing over a guard it never reached.
  • R3-9 (the only concurrent witness never asserted the reported ordering occurred): instrumenting it showed admission had won 0 of 200 rounds. Now 600 rounds, half of them holding the release until the admission commits, both outcomes asserted; dropping FOR UPDATE from beginSessionRelease fails 3 runs out of 3 with 3–5 contradictions.
  • R3-13 (contract javadoc claimed atomicity its single-threaded legs cannot show) → narrowed at 6460cd9387.
  • R3-14 (the busy leg could not distinguish a session-scoped from a binding-scoped predicate) → sibling-session leg added on both backends at 6460cd9387.
  • R3-8 → renamed to drop the claim nothing asserted, and extended to null / ZERO / -1ms / 999ms; the placement half stays unpinned and is declined in the thread.

Declined, with evidence in each thread

  • R1-16 — both classes carry a class-level @Timeout(180), so an unbounded join() fails the test with a report rather than hanging the job.
  • R2-3 — the one-shot cooldown eviction under a backwards clock is bounded by the step and confined to the automatic-observe path; re-arming becomes a perpetual per-entry timer under the injected clocks the cooldown tests use.
  • R3-6 — the in-memory type gate: relaxing it was tried, and the JDBC half of the shared contract fails with Release requires the same DataSource, so both gates are the same precondition. Tracked in test(runtime-broker): InMemory repositories drift from JDBC semantics, hiding JDBC-only races from service tests #13204.

Deferred to #13275 under this repo's ~5-review-round rule (three bot rounds plus five self-audit rounds; the last waves land Critical fixes only)

  • R3-10, R3-11, R3-12 — test oracles that cannot currently fail for the property they are named after. Each has its concrete fix written down in the issue, and none is a production defect.

Verification at 6460cd9387: runtime-broker 634 tests / 0 failures / 2 skipped with checkstyle:check and spotbugs:check clean; managed-agent-server fix-adjacent 19/19 against the reinstalled jar. windows-latest / Java 21 is green again after the two Windows fixes in 56c209634b.

wenshao added 2 commits October 3, 2026 18:59
The cooldown stamp ran for any throwable, so an Error escaping the lookup
was cached as a synthetic UNRESOLVED answer. The first caller still saw the
failure, but every cooled observer arriving within the next second got a
successful stage, and the HTTP face answered 409
runtime_broker_execution_unknown with retryable=false and no trace of the
internal failure. Both neighbouring paths already refuse that downgrade —
the takeover handle checks that the unwrapped error is not an Error, and the
HTTP observation rethrows one — so the cache was the single place that
turned an Error into a normal answer.

The stamp now skips Errors. A lookup that failed with an ordinary exception
still cools as before, which is what failedUnknownLookupCoolsDownLikeACompletedOne
pins: the Runtime was just unreachable and hammering it changes nothing,
whereas an Error means the process itself is broken and the next observation
should try.

anErrorDoesNotArmTheObservationCooldown throws from the transport's status
call, asserts the observation fails, then asserts that the next observation
inside the same cooldown window reaches the worker again. Removing the Error
filter fails it (expected 2 worker status calls, got 1) while the
failed-lookup cooldown test stays green, so the filter excludes Errors and
nothing else.

Suites green: runtime-broker 635 tests / 0 failures / 2 skipped with
checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19
against the reinstalled jar.
Round 5 found no Critical and three Suggestions, all about claims and
oracles rather than behaviour.

The managed-context refusal fix from the previous commit was described as
keeping a rolling restart from permanently failing the turn that raced it.
It does not: RuntimeBrokerService blocks recovery for any managed-context
provision failure, retryable or not. What the fix actually preserves is the
refusal's own message and retryable flag for callers that provision
directly, and it stops reporting "recovery is blocked" when the provisioner
was merely closed. The production comment and the test's javadoc now say
that instead, so the next reader does not re-derive the wrong invariant.

Both forked tests judged liveness with ProcessHandle.isAlive(), which counts
a Linux zombie as alive. The module already owns the fix for that in
ProcessTrees.running(); combined with the identity filter's
unreadable-command fallback, an unreaped zombie on a container runner would
have read as a live worker for the whole 20 s poll and failed a test whose
worker was dead. Both sites now use running().

The backoff's tail is pinned by equality rather than by bounds. The bounds
version could not see an in-bounds schedule that dipped back to 100 ms on
every other attempt, which restores the 10/s polling the backoff exists to
remove; the equality version fails it at attempt 9 and still fails an
unclamped shift at attempt 57. The wide range is about the tail sitting
exactly at the cap, not about the window having no ceiling.

The race helper closes its provisioner in a finally, so a throw mid-helper
cannot leave a wedged worker alive for the rest of the surefire JVM, and
asserts that the closer thread finished, so a wedged close() names itself
instead of surfacing as the lingering-children failure.

Suites green: runtime-broker 635 tests / 0 failures / 2 skipped with
checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19
against the reinstalled jar.
@wenshao

wenshao commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Self-audit round 5 (undirected + adversarial) — dispositions and convergence

Two passes over 4e91dc5. The undirected one independently re-derived the RELEASING re-entry regression — the same defect as bot thread R3-1, fixed in 445964f while it was running — and found one new Suggestion. The adversarial one found no Critical and three Suggestions. Everything is fixed across 608f6d6 and 51a526d.

The Critical both passes converged on

The undirected pass traced the same regression from the other direction: at 4e91dc5 releaseSession kept only the process-local hasActiveControl() test, and transitionSessionToReleasing returned an already-RELEASING row before reaching beginSessionRelease, so a release retry over such a row told the worker to release and marked the row RELEASED over a live execution. It also verified what the merge base did — hasActiveControl() || executionRepository.hasActiveByRuntimeSession(...) on every attempt — i.e. this branch had narrowed an existing safety check, which is exactly how R3-1 and my three-arm rig characterised it. Fixed in 445964f; deleting the re-check fails releaseRetryOverAPersistedReleasingRowRefusesALiveExecution.

That makes three independent sources converging on one defect (bot review, the verification rig, this pass) and no other Critical in five rounds.

Fixed

  • An Error was cached as an UNKNOWN observation (undirected) → 608f6d6. The cooldown stamp ran for any throwable, so an Error escaping the lookup was served to every cooled observer arriving within the next second as a successful UNRESOLVED reconciliation — HTTP 409 runtime_broker_execution_unknown, retryable=false, with no trace of the internal failure — while both neighbouring paths (the takeover handle, the HTTP observation) explicitly refuse to downgrade an Error. The stamp now skips them; an ordinary failed lookup still cools, which failedUnknownLookupCoolsDownLikeACompletedOne pins. Proof: removing the filter fails the new test (expected: <2> but was: <1>) and leaves the failed-lookup test green, so the filter excludes Error and nothing else.
  • A fix was described as doing something it does not (adversarial S1) → 51a526d. The previous commit's comment claimed the managed-context refusal fix keeps a rolling restart from "permanently failing the turn that raced it". RuntimeBrokerService blocks recovery for any managed-context provision failure, retryable or not, so that claim was false. What the fix actually preserves is the refusal's own message and retryable flag for callers that provision directly, and it stops reporting "recovery is blocked" when the provisioner was merely closed. The production comment and the test's javadoc now say that.
  • Both forked tests counted a Linux zombie as alive (adversarial S2) → 51a526d. ProcessHandle.isAlive() reports a zombie as alive, and on Linux a zombie's /proc/<pid>/exe readlink fails, so the identity filter's unreadable-command fallback (orElse(true)) would have held alive for the full 20 s poll — a false red on a runner whose PID 1 does not reap. The module already owns the fix for this in ProcessTrees.running(); both sites use it now. The identity filter itself was verified safe on all three platforms (Windows QueryFullProcessImageNameW, Linux /proc readlink with a cmdline fallback, and macOS empirically through the discovery filter, which uses orElse(false) and would have failed the run that reported green).
  • The backoff's tail was pinned by bounds instead of by value (adversarial S3) → 51a526d. An in-bounds schedule dipping back to 100 ms on every other attempt — which restores the 10/s polling the backoff exists to remove — passed the bounds version and the 0–7 list. The tail is now asserted equal to 2 s for attempts 8–100 000. Proof: that exact mutant fails at the backoff left the cap at attempt 9 ==> expected: <2000> but was: <100> while the wall-clock test stays green, and an unclamped shift still fails at attempt 57.
  • The race helper could leak a wedged worker, and could not distinguish a wedged close() from a lingering child (adversarial, reported under "no finding") → 51a526d. It now closes the provisioner in a finally and asserts the closer thread finished after the join.
  • The zh-CN design doc was missing the EN paragraph's closing clause about the un-fixed fan-out (undirected) — fixed in 445964f. That clause is the documents' only record of a known limitation left open, so a Chinese-only reader would have concluded finding 3 was fully fixed.

Convergence

Round 5 is the last full round under the stated rule (Suggestions in scope through round 5, Criticals only afterwards). Its yield: one Critical that three sources converged on and that is fixed, plus five Suggestions of which four were claim/oracle corrections and one a convention fix — no new production race, deadlock, security or data-loss defect. Both passes reported the areas they attacked and found solid, including the four blind spots round 5 was aimed at:

  • The seam with the harness-drain feature that arrived from main mid-branch. The drain funnels into this PR's primitive (drainClaimedBinding → drainSessions → releaseSavedSession → releaseSession → beginSessionRelease) with the binding already DRAINING, which RuntimeAdmission.requireRelease accepts and RuntimeHarnessDrainTest drives against JDBC/H2. A busy session under drain is refused before the primitive is reached (the drain's own hasActiveByBinding pre-check plus requireReady's drain refusal), so beginSessionRelease's busy branch is unreachable there by construction. The LOST drain loop and a live drain claim are serialised by the binding's operation claim, so their interleaving is a claim conflict, not state corruption.
  • The durable defaults flipped on by feat(managed-agent): default durable local-process and trusted reboot recovery on #13211. With durable-local-process=true, store != null, so the exit hook is not registered and close() skips terminateAll(): the shutdown-escalation half of this PR is inert in the shipped default configuration, and the release-grace tracking is non-durable-only because release() calls stop() only when store == null. The design doc's "for non-durable provisioners" wording already says this.
  • The SQL and lock ordering. Placement domain → binding → session → execution everywhere, with the new primitive taking a same-order subset (session FOR UPDATE, then a non-locking execution read, then an update of the row it already holds), so no cycle with admitExecution, completeSessionRelease, recoverLost or the renewal ticks. Both "active" predicates match their table's state enum, the new per-pass reads are index-covered, and the 409s thrown inside the transaction propagate unretyped, so code, message and retryable are byte-identical to the conflict(...) they replaced.
  • The HTTP face. Constant-time token comparison, the loopback refusal before HttpServer.create on the same InetSocketAddress (no re-resolution seam), all three observe(...) call sites updated, and no response path that can carry the token.

Remaining Suggestions are recorded in #13275: three test oracles that cannot currently fail for the property they are named after, and the reconcile=true fan-out decision. I am treating the audit as converged.

Verification

  • packages/sdk-java/runtime-broker: mvn clean test → 635 tests, 0 failures, 0 errors, 2 skipped; mvn checkstyle:check and mvn spotbugs:check clean.
  • packages/sdk-java/managed-agent-server (fix-adjacent): 19/19 against the reinstalled jar.
  • Two mutation proofs this round (the Error-cache filter, and the in-bounds tail mutant the old bounds assertion could not see), both reverted from cp backups.

@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

The red Hosted process fault gates / MySQL 8.4 / Java 21 check on 51a526d is #13255, not this PR

The job failed in HostedWorkspaceToolTurnIT.packagedHarnessUsesSavedWorkspacesThroughRealBrokerWorkerAndSqlStore:

qwen serve: Hosted Harness turn 095f5aac-… failed: Error: Hosted Workspace profile refused a tool call.
qwen serve: Hosted Harness turn de176687-… failed: Error: Hosted Workspace profile refused a tool call.
AssertionError [ERR_ASSERTION]: {"error":"hosted_turn_recovery_required","code":"hosted_turn_recovery_required"}
409 !== 200
    at javaLoad (integration-tests/helpers/hosted-workspace-tool-turn-driver.ts:513)

Three things place it outside this PR:

  • The same failure, with an identical signature, is on unrelated branches right now — including a docs-only branch, which cannot have caused it: docs/h3-shell-monitor-design @ 8173837 (8 tests, 1 failure, 18.69 s) and fix/managed-agent-quality-hardening @ eb6c54c (8 tests, 1 failure, 20.85 s), against this branch's 18.84 s. Same method, same two refused a tool call turns, same hosted_turn_recovery_required 409.
  • The refusal is raised client-side in the driver's tool-call validation (packages/cli/src/serve/hosted-workspace-tool-turn.ts:726), which fires on an undeclared tool name, a duplicate callId, wasOutputTruncated or hadIncompleteArguments — none of which this PR touches. The managed-agent-server logged no WARN or ERROR in the failure window, so no broker 409/503 preceded it. This PR changes only packages/sdk-java/** and documentation.
  • The same job passed on this branch at af6691c and 20d8f48, and HostedWorkspaceToolTurnIT was 8/8 green in the latter's failsafe report.

It is tracked as #13255, which has an in-flight fix branch. Re-running the failed jobs (attempt 2) to confirm.

@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Re-ran: Hosted process fault gates / MySQL 8.4 / Java 21 is green on 51a526d (attempt 2, job 111201300466), confirming the earlier red was #13255 rather than this PR.

Worth recording, because the first attempt hid more than one check: it failed at step 9, which skipped steps 10-15 — including step 12 Run Runtime Broker fault gates and both owner-failover E2Es. Those had therefore never executed on any commit of this branch after af6691c (the intermediate runs were superseded by newer pushes). Attempt 2 ran all of them to success:

9   success  Verify Hosted Java, Spring and MySQL processes
10  success  Check that every Hosted integration test class ran
11  success  Check Hosted latency measurements
12  success  Run Runtime Broker fault gates
13  success  Install MySQL binaries for the failover E2E
14  success  Run in-flight owner failover E2E
15  success  Run continuation owner failover E2E

So the concurrency and fault-injection gates most relevant to this PR — the storage fault gates against the release transition, and the in-flight and continuation owner-failover E2Es — have now run green on the final head, alongside the Java matrix (ubuntu 11/17/21, macOS 21, Windows 21), MariaDB, Real daemon E2E and the Node lanes. Check tally: 21 pass / 0 fail / 8 skipped, with only the review-pr automation still pending.

…es not give

The bot's round-4 review found a Critical in this branch's own hardening
record rather than in its code. The design doc claimed a parked renewal tick
"no longer delays coordination work", and managed-runtime-broker-service-core
claimed a stalled storage call "cannot queue coordination work". Neither
holds: a tick parked inside its JDBC call still holds its claim's renewal
monitor, and three of the paths that take that monitor run as tasks on the
single-thread coordination scheduler — the provisioning fence's stopAndGet(),
the reclaim fence's close(), and reconcileLoop's close() — so a stall long
enough to park a tick can still block that thread, and with it every other
execution's result polls, fences, retries and cooldown evictions. No renewal
JDBC call sets a statement or socket timeout.

The behaviour is not a regression. At the merge base the parked tick was the
coordination thread, so the split strictly narrowed the harm; the defect was
the certification, which is the one thing a hardening record exists to state
accurately. The next responder to a frozen broker would have ruled this class
out on the strength of that sentence and looked elsewhere while the
coordination thread sat blocked in the frame the original incident produced.
Both documents, in both languages, now say what the split buys and what it
does not, and name the two changes that would close the rest — a
statement/socket timeout on renewal JDBC, or a monitor-free renewal handle —
as tracked in #13275 rather than done here.

The same review found the 2 s poll cap described as a bound on how late a
finished result is picked up: in the design doc, in both languages, and in
the v3PollDelayMillis javadoc this branch added. A single-thread executor
guarantees a task starts no earlier than its delay and never guarantees it
starts within it, and every execution's rounds share that thread, so pick-up
lateness under concurrency is the cap plus the thread's queue wait. All
three now say that.

Two older documents are corrected alongside, and one log line added. The
2026-09-23 process-adoption page still promised that stop and close never
escalate past SIGTERM, which this branch's bounded-force shutdown falsifies
for non-durable workers; it now states the 5 s grace and the three places it
runs, and cross-links the hardening page in both languages. Its neighbouring
sentence — a Broker crash can still orphan the worker — stays, because a
SIGKILLed broker runs no hook. And the embedded broker logs the resolved v3
result window at startup, so a suffix-less "30" that bound as 30
milliseconds is visible in the log instead of only in degraded tool calls.

Suites green: runtime-broker 635 tests / 0 failures / 2 skipped with
checkstyle and spotbugs clean, managed-agent-server fix-adjacent 19/19
against the reinstalled jar.
@wenshao

wenshao commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Bot review round 4 (R4-1 … R4-3) plus two round-1 threads that a pagination window had hidden — all answered

Fixed in b81f21d. The Critical was in this branch's own hardening record rather than in its code.

R4-1 (Critical) — the design doc certified a renewal isolation the code does not provide. Verified against the code, not just the report: BindingRenewal.stopAndGet()/close() and DispatchRenewal.close() are synchronized on the renewal instance, the same monitor renew() holds while parked inside its JDBC call, and three of the callers are tasks on the single-thread coordination scheduler (the provisioning fence, the reclaim fence, the reconcile re-entry). No renewal JDBC call sets a statement or socket timeout. So a stall long enough to park a tick can still block the coordination thread behind that monitor — and with it every other execution's polls, fences, retries and cooldown evictions. The claim I wrote in round 1 ("it no longer delays coordination work") and managed-runtime-broker-service-core.md's stronger one ("a stalled storage call cannot queue coordination work") were both false in general. Both documents, in both languages, now state what the split buys (a parked tick is no longer itself the coordination thread, which at the merge base it was) and what it does not, and name the two changes that would close the rest as tracked in #13275 item 5 rather than done here. No code change: at the merge base the parked tick was the coordination thread, so the split strictly narrowed the harm — the defect was the certification.

R4-2 — the 2 s poll cap was described as a bound on pick-up lateness in the design doc (both languages) and in the v3PollDelayMillis javadoc this branch added. A single-thread executor guarantees a task starts no earlier than its delay, never within it, and every execution's rounds share that thread, so lateness under concurrency is the cap plus the queue wait. All three corrected.

R4-3 — the 2026-09-23 process-adoption page still promised stop/close never escalate past SIGTERM, which this branch's bounded-force shutdown falsifies for non-durable workers. Corrected in both languages with a cross-link to the hardening page; the neighbouring "a Broker crash can still orphan the worker" sentence stays, since a SIGKILLed broker runs no hook. I did not adopt the suggested "durable workers are never force-killed here" phrasing, because the drained path does escalate.

R1-2 and R1-3 were never answered: my earlier thread sweep used a last:30 window on a PR that has 47 threads, so the two oldest fell outside it. Both are now answered in-thread. R1-2's unit trap is guarded (1 s floor, validated before the provisioner exists, four shapes tested) and documented; the missing piece — nothing logged the resolved window — is fixed by logging it at startup, so a suffix-less 30 shows up as PT0.03S. Its 24 h ceiling and ≥60 s floor are declined as config policy, and R1-3's latency half is already fixed by the 2 s cap (now pinned deterministically for attempts 0–100 000); its deadline-boundary case is recorded as #13275 item 6.

Verification: runtime-broker 635 tests / 0 failures / 2 skipped with checkstyle and spotbugs clean; managed-agent-server fix-adjacent 19/19. (The first run of the consumer suite failed to compile against a stale qwencode jar in the local Maven repository — this branch's own qwencode was installed and it went green; no source issue.)

wenshao added 2 commits October 4, 2026 08:30
The commit that narrowed the design docs left the code comment certifying
the same absolute guarantee: newRenewalScheduler() still said one stalled
storage call "cannot queue retries, polls, and every other binding's
renewal past its lease". That is the sentence the docs no longer say, and
it sits at the definition a maintainer changing renewal threading reads
first, some 3700 lines away from the narrowed paragraph. It now states
what the split buys — a stalled tick no longer occupies the coordination
thread, and a second thread absorbs one slow call — and what it does not:
the tick holds its claim's monitor while parked, and the fence and
settlement paths take that monitor from coordination tasks, so a long
stall can still block coordination. #13275 carries the two changes that
would close the rest.

Also bump the freshness stamp on the 2026-09-23 process-adoption page in
both languages. The previous commit rewrote its SIGTERM/SIGKILL sentence
to record the bounded-force escalation but left `Updated: 2026-09-24`,
which made the page claim to predate the 2026-10-02 hardening cited eight
words later in the same paragraph; the date line sits outside the diff
hunk, so the contradiction was invisible in review. The other four design
docs this branch edits carry no Updated field, so no other stamp is stale.

Comment and documentation only, no behaviour change. Verified with
test-compile, checkstyle:check and spotbugs:check.
…threads

The dispatch-renewal test's javadoc said a stalled binding renewal cannot
starve dispatch renewals. With a two-thread pool that holds for one stall
and for two, and stops there, so the comment now says what the pool size
actually buys instead of an absolute guarantee - the same overclaim class
the design docs and newRenewalScheduler()'s comment were just corrected
for. Comment only.
@wenshao

wenshao commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

Real-stack verification, round 2 — head 5cd1c832f4

Verdict: from the real-stack side this is ready to merge. Round 1's F1 (bot R3-1) is fixed with exactly the check I proposed, and verified end to end. The new grace-window fix also holds on a real worker. Every round-1 scenario was re-run on the new head, and nothing regressed.

One non-blocking suggestion is new (G1): the InnoDB statement order that the release fix depends on is pinned by no test that runs in CI. A mutant that breaks it reopens the race on MySQL and passes all 635 unit tests plus the existing MySQL IT. A 68-line IT that kills it is attached.

The head did not change during the run; the PR adds 10 commits on top of af6691ce1f with no main merge. A trial merge with today's main (2c591ecc08, which only touches managed-agent-server/README.md inside sdk-java) is clean. CI is 21 pass / 0 fail; review-pr is still running. reviewDecision still shows the bot's older CHANGES_REQUESTED.

Re-run on the same rig

Rig: real MySQL 8.4.7; separate Broker JVMs; the bundled worker; the real managed-agent-server jar. The arms are base 5130c1a734, round 1 af6691ce1f, and round 2 5cd1c832f4, each identified from its own bytecode.

scenario base round 1 round 2
Two-JVM release vs admission, 300 rounds (contradictions) 70/300 0/300 0/300
same, +5 ms per DB hop, 200 rounds 89/200 0/200 0/200
R3-1: release over a seeded RELEASING + PREPARED row 409 busy 200 released=true ❌ 409 runtime_session_busy, row stays RELEASING, worker not told
idle RELEASING row (idempotent retry) — 200 200
renewal parked in a real InnoDB lock wait: healthy execution on another binding UNKNOWN SETTLED SETTLED / success
two parked renewals (pool bound, now stated in the design doc) — UNKNOWN UNKNOWN
Spring SIGTERM with the worker frozen survives killed +5.1 s killed +5.1 s
library JVM SIGTERM: healthy / handshake worker orphaned / outlives +0.1 s / +0.1 s +0.1 s / +0.1 s
LOST reclaim, 303 sessions (simulated power loss) 503 → 200 200 200
shipped MCP poller ?reconcile=true, 20 s (GETs / worker status calls) 60 / 27 64 / 29 56 / 24 — unchanged by design, now documented and tracked in #13275

Spring startup matrix (14 cases × 3 arms): same posture as round 1. The startup log now prints the resolved window (listening at … (v3 result window PT30M)). PORT=70000 fails with Runtime Broker listener could not start: port out of range:70000; round 1 named no cause (R3-2).

Test suites:

  • runtime-broker: 635 tests, 0 failures (2 skipped); SpotBugs 0; Checkstyle 0.
  • MySQL IT profile: 7/7 on a fresh database.
  • managed-agent-server fix-adjacent: 19/19.

round 2

New fix verified: the release grace window (56c209634b)

Setup:

  • The worker: the real bundled worker, launched with a preload that drops every SIGTERM listener, modelling a wedged graceful shutdown.
  • The release: the proxy drops the service's attestation reply, so provisioning fails and releaseQuietly → provisioner.stop() runs.
released worker ignores SIGTERM base round 1 round 2
Broker keeps running still running at +9.5 s killed +4.98 s killed +4.97 s
Broker JVM gets SIGTERM inside the 5 s grace window orphaned (ppid 1) orphaned (ppid 1) killed +4.96 s

grace window

G1 — the load-bearing InnoDB statement order is pinned by no test (suggestion)

The new comment in JdbcRuntimeBindingRepository.beginSessionRelease is right: under REPEATABLE READ the execution read must be the transaction's first consistent read, taken after the FOR UPDATE. I checked it with mutant M1, which adds one consistent read (hasActiveByRuntimeSession, result ignored) ahead of the locking read (mutant-m1.patch).

oracle head M1
two-JVM race on real MySQL, 300 rounds 0 contradictions 43 contradictions
runtime-broker unit suite (H2), including the 200-round stress 635 / 0 failures 635 / 0 failures — survives
existing mysql-integration IT profile 7/7 7/7 — survives
candidate IT, MySQL 8.4.7 10/10 green 3/3 red
candidate IT, MariaDB 10.11.18 (the CI mysql-integration image) 5/5 green 3/3 red

Candidate: candidate-innodb-order-it.patch, JdbcRuntimeBrokerMySqlIT.releaseTransitionSeesAnAdmissionThatCommittedWhileItWaited, 68 lines, Checkstyle clean. How it works:

  1. A holder connection takes the Session row FOR UPDATE.
  2. fixture.prepare(...) queues on that lock first, and beginSessionRelease queues behind it. Both waits are confirmed in information_schema.innodb_trx, scoped to the IT's schema.
  3. The holder commits.
  4. Expected: the admission is PREPARED, the release gets 409 runtime_session_busy, and the Session stays READY. M1 fails with Expected ExecutionException … nothing was thrown: the release went to RELEASING over the admitted execution.

It needs nothing new in CI; it runs in the existing MariaDB mysql-integration job.

innodb order

Notes

  • N1 (pre-existing, unrelated): JdbcRuntimeBrokerMySqlIT.managedRecoveryKeepsPinsUntilTheOriginalHolderCleanupCompletes fails with Runtime credential key is unavailable when the profile is re-run against a database it already ran on. Base fails the same way on its second run. The CI job uses a fresh service container, so this does not affect CI.
  • Not re-exercised on the real stack this round, so pinned only by the PR's unit tests: R3-5 (the closed-provisioner refusal stays retryable) and the Error-not-cached change in lookupOnce.

Evidence: assets-pr13214@31717330. It holds the round-2 harness additions (grace.mjs, the ignore-term preload, the extended Spring matrix, the IT runners) and the raw results and logs. Round-1 material stays under pr13214/.

中文版

真实环境验证第二轮 — head 5cd1c832f4

结论:从真实环境验证的角度看,可以合并。 第一轮的 F1(bot R3-1)已用与我当时提议完全相同的检查修复,并端到端验证通过。新增的宽限期修复在真实 worker 上同样成立。第一轮的全部场景都在新 head 上重跑了一遍,没有回归。

新增一条不阻塞的建议(G1):释放修复所依赖的 InnoDB 语句顺序,没有任何在 CI 里运行的测试钉住。破坏这一顺序的变异体在 MySQL 上会重新打开竞态,却能通过全部 635 个单测和现有的 MySQL IT。我附上了一个能杀死它的 68 行 IT。

验证期间 head 未变;PR 在 af6691ce1f 之上新增了 10 个提交,没有合并 main。与今天的 main(2c591ecc08,在 sdk-java 范围内只改了 managed-agent-server/README.md)试合并干净。CI 21 项通过、0 失败,review-pr 仍在运行。reviewDecision 仍显示 bot 较早的那次 CHANGES_REQUESTED。

同一装置重跑

装置:真实 MySQL 8.4.7;独立的 Broker JVM;打包 worker;真实的 managed-agent-server jar。三臂为 base 5130c1a734、第一轮 af6691ce1f、第二轮 5cd1c832f4,各自都按字节码确认了身份。

场景 base 第一轮 第二轮
两个 JVM 释放与准入竞态,300 轮(矛盾态次数) 70/300 0/300 0/300
同上,每跳 +5 ms,200 轮 89/200 0/200 0/200
R3-1:对预置的 RELEASING + PREPARED 行发起释放 409 busy 200 released=true ❌ 409 runtime_session_busy,行保持 RELEASING,worker 未收到释放
空闲的 RELEASING 行(幂等重试) — 200 200
续约卡在真实 InnoDB 锁等待中:另一 binding 上的健康 execution UNKNOWN SETTLED SETTLED / success
两个续约同时卡住(线程池上限,设计文档已写明) — UNKNOWN UNKNOWN
Spring 收到 SIGTERM,worker 已被冻结 存活 +5.1 s 被杀 +5.1 s 被杀
嵌入式库 JVM 收到 SIGTERM:健康 worker / 握手中的 worker 孤儿 / 存活更久 +0.1 s / +0.1 s +0.1 s / +0.1 s
LOST 回收,303 个 session(模拟断电) 503 → 200 200 200
随产品发布的 MCP 轮询 ?reconcile=true,20 s(GET 次数 / worker status 调用) 60 / 27 64 / 29 56 / 24(按设计不变,已写入文档并在 #13275 跟进)

Spring 启动矩阵(14 个用例 × 3 臂)的监听行为与第一轮一致。启动日志现在会打印解析后的窗口(listening at … (v3 result window PT30M))。PORT=70000 现在报 Runtime Broker listener could not start: port out of range:70000,第一轮不说明原因(R3-2)。

测试套件:

  • runtime-broker: 635 个测试 0 失败(2 个跳过);SpotBugs 0;Checkstyle 0。
  • MySQL IT profile: 新库上 7/7。
  • managed-agent-server 修复相关测试: 19/19。

新修复验证:释放宽限期(56c209634b)

装置:

  • worker: 真实打包 worker,以丢弃所有 SIGTERM 监听器的 preload 启动,模拟卡死的优雅关停。
  • 释放: 代理丢弃 service 的 attestation 应答,使 provisioning 失败,触发 releaseQuietly → provisioner.stop()。
被释放的 worker 忽略 SIGTERM base 第一轮 第二轮
Broker 继续运行 +9.5 s 时仍在运行 +4.98 s 被杀 +4.97 s 被杀
Broker JVM 在 5 s 宽限期内收到 SIGTERM 成为孤儿(ppid 1) 成为孤儿(ppid 1) +4.96 s 被杀

G1 — 承重的 InnoDB 语句顺序没有测试钉住(建议)

JdbcRuntimeBindingRepository.beginSessionRelease 里新增的注释是对的:在 REPEATABLE READ 下,execution 读取必须是事务的第一次一致性读,并且发生在 FOR UPDATE 之后。我用变异体 M1 做了验证:在加锁读之前加一次一致性读(hasActiveByRuntimeSession,忽略结果)(mutant-m1.patch)。

判据 head M1
真实 MySQL 上的两 JVM 竞态,300 轮 0 次矛盾 43 次矛盾
runtime-broker 单测(H2),含 200 轮压力测试 635 / 0 失败 635 / 0 失败 —— 存活
现有 mysql-integration IT profile 7/7 7/7 —— 存活
候选 IT,MySQL 8.4.7 10/10 通过 3/3 失败
候选 IT,MariaDB 10.11.18(CI mysql-integration 用的镜像) 5/5 通过 3/3 失败

候选补丁:candidate-innodb-order-it.patch,即 JdbcRuntimeBrokerMySqlIT.releaseTransitionSeesAnAdmissionThatCommittedWhileItWaited,68 行,Checkstyle 干净。做法:

  1. 一个 holder 连接对 Session 行加 FOR UPDATE 锁。
  2. fixture.prepare(...) 先在这把锁上排队,beginSessionRelease 排在它后面。两个等待都在 information_schema.innodb_trx 中按本 IT 的 schema 确认。
  3. holder 提交。
  4. 期望:准入结果为 PREPARED,释放得到 409 runtime_session_busy,Session 仍是 READY。M1 下失败为 Expected ExecutionException … nothing was thrown:释放越过已准入的 execution 进入了 RELEASING。

CI 无需任何新增,它会在现有的 MariaDB mysql-integration 任务中运行。

备注

  • N1(既有问题,与本 PR 无关): 对同一个库重跑 profile 时,JdbcRuntimeBrokerMySqlIT.managedRecoveryKeepsPinsUntilTheOriginalHolderCleanupCompletes 会报 Runtime credential key is unavailable。base 第二次运行同样失败。CI 每次使用全新的服务容器,因此不受影响。
  • 本轮未在真实环境中重新演练、只由 PR 单测钉住的两项:R3-5(关闭后的 provisioner 拒绝仍可重试)、lookupOnce 不缓存 Error。

证据:assets-pr13214@31717330,包含第二轮新增的装置(grace.mjs、ignore-term preload、扩展后的 Spring 矩阵、IT 运行脚本)以及原始结果和日志。第一轮材料仍在 pr13214/ 下。

@wenshao

wenshao commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is queued for an available runner; follow the workflow run for progress. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes.

@wenshao

wenshao commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

Real-stack verification, round 3 — bot round-6 findings re-checked (head still 5cd1c832f4)

Verdict: unchanged from round 2 — ready to merge from the real-stack side. The head has not moved since round 2, so this round checks the four [probe] findings the bot recorded in round 6 (none Critical, all deferred) against the real stack, and re-checks the PR against today's main.

One of those findings, D6-8, appeared to contradict my round-2 G1. Measured, both are right about different mutants, and the suggested fix is the same either way.

CI: 23 pass, 0 fail.

D6-8 vs round-2 G1: which read reordering does H2 catch?

oracle head M1 — an extra consistent read added before FOR UPDATE (round 2) M2 — the execution read hoisted before FOR UPDATE (bot D6-8)
two-JVM race, real MySQL 8.4.7 (REPEATABLE READ), 300 rounds 0 contradictions 43 7
PR's H2 stress concurrentCrossProcessAdmitAndReleaseNeverContradict, 30 runs each, host load 100–150 0/30 red 0/30 red — invisible to H2 30/30 red — H2 catches it
candidate InnoDB IT, MySQL 8.4.7 10/10 green 3/3 red 3/3 red
candidate InnoDB IT, MariaDB 10.11.18 (round 2) 5/5 green 3/3 red not run

The H2 data source in the test's URL shape runs at READ_COMMITTED (I measured: H2 2.4.240 isolation=2; nothing in runtime-broker overrides it). At that isolation every statement sees the latest commit:

  • M2 (hoisted read): the read happens before the lock, so it misses an admission committed in between. It loses the race on H2 too, and the PR's stress test catches it. D6-8 is right.
  • M1 (added read): this only matters under InnoDB REPEATABLE READ, where the first consistent read freezes the snapshot. On H2 the later read still sees the admission, so all 30 stress runs stay green. G1 is right.

So the comment "H2 would not show it" is wrong for a hoist and right for an added read. The rule it states is correct.

Suggested fix (one change addresses both findings): word the comment as "H2 runs READ COMMITTED: it catches a hoisted read, but not an extra read ahead of the lock, which only breaks under InnoDB REPEATABLE READ", and add the round-2 candidate IT, which kills both mutants deterministically. Patches:

The other round-6 probe findings

  • R1-2 / D6-4 — confirmed on the real Spring server. QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW=1800, meant as 30 minutes, starts and logs (v3 result window PT1.8S); 3600 → PT3.6S; 500 is still refused. The same holds on the trial merge with main. The new startup log line makes the mistake visible, but nothing stops it. The bot's @DurationUnit suggestion would close it at the binding.
  • D6-7 — confirmed by rounds 1 and 2. With one renewal parked in a real InnoDB lock wait, the healthy execution on another binding settles. With two parked, it ends UNKNOWN. The pool absorbs one stall, so the javadoc's "two simultaneous stalls" is off by one.
  • D6-3 (the dispatch settlement blocks the coordination thread) and D6-6 (the managed-context drain exit is untested) — not reproduced here. They need a v3 publication or a managed-context placement, which this rig doesn't drive. They rest on the bot's unit-level probes.

Against today's main (6136786c0c)

main moved by 7 commits that touch sdk-java: the Druid pool swap (#13363), a V35 migration, and harness changes. Three of the files they change are also changed by this PR: ManagedAgentProperties, application.yml and EmbeddedRuntimeBrokerTest. Results on the merged tree:

  • The trial merge is textually clean.
  • qwencode, runtime-broker and managed-agent-server all build. The fix-adjacent EmbeddedRuntimeBrokerTest + ManagedAgentPropertiesTest pass 19/19.
  • On the merged Druid jar, the Spring startup subset behaves exactly as on head: non-loopback refusal, ALLOW_NON_LOOPBACK opt-in, the 1 s floor, PT1.8S for 1800, and the port out of range message.

round 3

Evidence: assets-pr13214@b06d858c. It contains the StressRepeat driver (runs the PR's stress test N times per jar), the H2 isolation probe, the M2 patch, the merge build script and every run log. Rounds 1 and 2 stay under pr13214/ and pr13214/r2/.

中文版

真实环境验证第三轮 — 复核 bot 第 6 轮发现(head 仍为 5cd1c832f4)

结论:与第二轮相同——从真实环境验证的角度看,可以合并。 第二轮之后 head 没有变化,因此本轮做两件事:在真实环境中复核 bot 第 6 轮记录的 4 条 [probe] 发现(无 Critical,均为延后),并对照今天的 main 复验本 PR。

其中 D6-8 看起来与我第二轮的 G1 矛盾。实测表明两者针对的是不同的变异体,各自都对,而且建议的修法相同。

CI:23 项通过,0 项失败。

D6-8 与第二轮 G1:H2 能抓到哪一种读顺序重排?

判据 head M1:在 FOR UPDATE 之前新增一次一致性读(第二轮) M2:把 execution 读取提前到 FOR UPDATE 之前(bot D6-8)
两个 JVM 竞态,真实 MySQL 8.4.7(REPEATABLE READ),300 轮 0 次矛盾 43 7
PR 自带的 H2 压测 concurrentCrossProcessAdmitAndReleaseNeverContradict,每臂 30 次,宿主负载 100–150 0/30 失败 0/30 失败——H2 看不出来 30/30 失败——H2 能抓到
候选 InnoDB IT,MySQL 8.4.7 10/10 通过 3/3 失败 3/3 失败
候选 InnoDB IT,MariaDB 10.11.18(第二轮) 5/5 通过 3/3 失败 未跑

按测试所用 URL 形态建立的 H2 数据源运行在 READ_COMMITTED 隔离级别(我实测为 H2 2.4.240 isolation=2,runtime-broker 中没有任何地方覆盖它)。在这一级别下,每条语句都能看到最新提交:

  • M2(提前读取): 读取发生在加锁之前,会漏掉在此期间提交的准入。它在 H2 上同样会输掉竞态,PR 的压测能抓到。D6-8 成立。
  • M1(新增读取): 只有在 InnoDB REPEATABLE READ 下才有影响,因为首次一致性读会冻结快照。在 H2 上,加锁之后的那次读取仍能看到准入,所以 30 次压测全部通过。G1 成立。

因此注释“H2 would not show it”对“提前读取”不成立,对“新增读取”成立;它陈述的规则本身是对的。

建议修法(一处改动同时解决两条发现):把注释改为“H2 运行在 READ COMMITTED:它能抓到提前读取,但抓不到在锁之前新增的读取,后者只在 InnoDB REPEATABLE READ 下出错”,并加入第二轮的候选 IT,它能确定性地杀死两个变异体。补丁:

第 6 轮的其他 probe 发现

  • R1-2 / D6-4——在真实 Spring 上成立。 QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW=1800(本意是 30 分钟)能正常启动,日志显示 (v3 result window PT1.8S);3600 → PT3.6S;500 仍被拒绝。与 main 试合并后结果相同。新增的启动日志让这个错误能被看到,但无法阻止它。bot 建议的 @DurationUnit 能在绑定环节根治。
  • D6-7——第一、二轮的实测已证实。 一个续约卡在真实 InnoDB 锁等待中时,另一 binding 上的健康 execution 能正常完成;两个同时卡住时,它变为 UNKNOWN。线程池只能吸收一个卡住的续约,javadoc 中“两个同时卡住”差了一。
  • D6-3(分发结算阻塞协调线程)、D6-6(managed-context 清空循环的退出分支未被测试)——本装置未复现。 二者需要 v3 publication 或 managed-context placement,本装置不驱动这两者,目前只有 bot 的单元级 probe 支撑。

对照今天的 main(6136786c0c)

main 新增了 7 个涉及 sdk-java 的提交:Druid 连接池替换(#13363)、V35 迁移和若干 harness 改动。其中有三个文件本 PR 也改了:ManagedAgentProperties、application.yml、EmbeddedRuntimeBrokerTest。合并后的结果:

  • 试合并没有文本冲突。
  • qwencode、runtime-broker、managed-agent-server 均能构建;修复相关的 EmbeddedRuntimeBrokerTest + ManagedAgentPropertiesTest 19/19 通过。
  • 在合并后的 Druid jar 上,Spring 启动子集与 head 表现完全一致:拒绝非回环地址、ALLOW_NON_LOOPBACK 显式开启、1 秒下限、1800 解析为 PT1.8S、port out of range 提示。

证据:assets-pr13214@b06d858c,包含 StressRepeat 驱动(对每个 jar 重复运行 PR 的压测 N 次)、H2 隔离级别探针、M2 补丁、合并构建脚本和全部运行日志。第一、二轮的材料仍在 pr13214/ 和 pr13214/r2/ 下。

@wenshao

wenshao commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao
wenshao enabled auto-merge October 4, 2026 12:53

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Critical-only review at head 5cd1c832. No blocking issue found — approving.

The standing CHANGES_REQUESTED on this PR is stale: it was filed in round 4 against head 51a526df, and the two later rounds (at b81f21d2 and this head) were submitted as comments with no Critical. A later clean verdict does not by itself discharge an earlier Critical, so I verified all four historically-filed Criticals in the code at this head rather than reading the round-6 ledger's empty finding list as proof.

R1-1 (cooldown cache-hit pairing) — fixed. lookupOnce:1158-1183 no longer builds a fresh reconciliation out of the caller's snapshot. When the cached record's state is no longer UNKNOWN it returns the cached ExecutionReconciliation whole, with a comment naming the exact defect: pairing a terminal answer with the caller's pre-settlement UNKNOWN snapshot would report a settled execution as unknown. When both records are still UNKNOWN it takes the higher-version record and pairs it with the cached outcome, so a cancel landing between the two snapshots is not lost. The cache is consulted only under cooled and inside the deadline, and the mutation paths (:start, :cancel) pass false, so they never serve it.

R2-1 (POSIX-signal test with no OS guard) — fixed. FakeAttestationWorkerTest.ignoreTermSurvivesSigterm now carries @DisabledOnOs(OS.WINDOWS) at lines 161-162.

R3-1 (already-RELEASING re-entry left unchecked) — fixed, on both paths. JdbcRuntimeBindingRepository.beginSessionRelease performs the no-active-execution check inside the same transaction as the RELEASING transition, takes the Session row FOR UPDATE first, and documents why the statement order is load-bearing under InnoDB REPEATABLE READ — the plain execution read must be the transaction's first consistent read so its read view is built after the lock is held and therefore sees an admission that committed before it, with the note that H2 would not expose a reordering. The idempotent re-entry that beginSessionRelease:97-100 returns early for is covered separately by transitionSessionToReleasing:3452-3468, which re-checks hasActiveByRuntimeSession for an already-RELEASING row and refuses runtime_session_busy rather than completing a release that would free a worker still running a tool. finishSessionRelease needs no check of its own because it is only reached past that gate.

R4-1 (design doc over-claiming renewal isolation) — fixed. The "Renewal pool" decision now states the limit explicitly: a tick parked inside its JDBC call occupies one of the pool's two threads and can delay other renewals, the split "does not make coordination immune to a stall" because the parked tick still holds its claim's renewal monitor which the provisioning fence, reclaim fence and reconcile re-entry all take from coordination-scheduler tasks, and closing that needs a statement/socket timeout or a monitor-free renewal handle, deferred to #13275. That is a precise statement of what the change buys, not an absolute guarantee.

My own scan of the current diff, on the paths where a Critical would plausibly live. The loopback guard in RuntimeBrokerHttpServer is fail-closed in the right places: it refuses when address.getAddress() is null (an unresolved bind) as well as when it is non-loopback, it runs in the constructor before anything binds, and the pre-existing three-argument constructor delegates with allowNonLoopback = false, so every current caller keeps the safe default and the opt-in is purely additive for a face that serves one global Bearer token over plaintext HTTP. MIN_V3_RESULT_WINDOW is a 1 s floor with the constructor refusing a suffix-less value that would bind as milliseconds. The cooldown eviction is scheduled against the same clock the deadline check reads, so a service clock differing from the scheduler's wall time cannot evict an entry its reads still consider fresh, and the eviction's catch drops cooldown state on a closing service rather than leaking it.

Coverage, stated plainly so this approval does not over-claim. I read the release/admission transaction, the re-entry path, the cooldown cache-hit, the loopback guard and the config floor at head. I did not read LocalProcessRuntimeProvisioner's new destroy() → bounded grace → destroyForcibly() escalation, its starting-set tracking or its JVM exit hook, nor the EmbeddedRuntimeBroker / ManagedAgentProperties wiring, nor the ~2400 lines of new tests. CI on this head ran almost nothing — 2 checks successful, 97 skipped, 1 cancelled and no failures — so the Java lanes did not execute here and this verdict rests on reading the code, not on a green suite. Neither gap produced a finding I can evidence, and rounds 5 and 6 of the automated review filed none, which is why I am approving rather than deferring; if a maintainer wants the shutdown-escalation path attested, that is the part to look at.

The remaining open items I saw are Suggestions by their own grading (the renewal-scheduler comment wording, a design-doc sentence, the sub-second config floor) and round 4 deferred 20 more under its convergence posture, so I did not treat any of them as blocking or spend budget on them.

@wenshao
wenshao added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 5022712 Oct 4, 2026
187 of 190 checks passed
wenshao added a commit that referenced this pull request Oct 4, 2026
Resolve the RuntimeBrokerService conflict with #13214 (two-thread
renewalScheduler) by keeping the ReentrantLock conversion on the new
field: both BindingRenewal.start() and DispatchRenewal.start() now lock
the ReentrantLock monitor and schedule on renewalScheduler. Tree content
byte-identical to the clean rebase result ba919b5591.

Co-authored-by: Qwen-Coder <[email protected]>
wenshao added a commit to wenshao/qwen-code that referenced this pull request Oct 7, 2026
…#13565)

Move the merged-PR history of the Managed Agent dual-path proposal
(QwenLM#12380) out of the issue body into a bilingual ledger under
docs/design/. The issue body had come within 10 KB of GitHub's 256 KiB
limit, so the body will keep only the delivery snapshot and the open
PRs, and merged rows move here rewritten to their merged final state.

The ledger carries every merged row the issue tracked up to its
2026-10-03 reconcile (adding the missing merge commit to the five
earliest rows and normalising the Chinese state cells), rewrites the
eight rows the issue still listed as open although their PRs had merged
(QwenLM#13141, QwenLM#13166, QwenLM#13174, QwenLM#13210, QwenLM#13214, QwenLM#13217, QwenLM#13218, QwenLM#13247), and
adds rows for the 48 managed-agent PRs merged between that reconcile and
main 0c13502 that had no row yet. PRs closed without merging (QwenLM#13087,
QwenLM#13336) sit in their own table. Later merges land at the next reconcile.

Co-authored-by: wenshao <[email protected]>
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