Skip to content

feat(sdk-java): Add audited Hosted Workspace operator recovery - #12977

Merged
doudouOUC merged 9 commits into
mainfrom
codex/issue-12904-operator-recovery
Sep 30, 2026
Merged

doudouOUC merged 9 commits into
mainfrom
codex/issue-12904-operator-recovery

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Adds an offline, opt-in workspace-recovery inspect|prepare|complete procedure for a Linux durable Hosted worker whose partial Shell capture has pinned its Workspace lease. Preparation records the exact holder and original execution, then fences the binding without freeing the Workspace. Completion requires a private operator statement, verifies that the registered worker has exited, tombstones its durable identity, and reuses the existing exact-holder cleanup to make the Workspace available to a new Session. The original turn and uncertain Shell outcome remain blocked and are never replayed.

Adds a permanent audit trail, synchronized English design and Chinese design, plus an English runbook and Chinese runbook. The command does not start the HTTP service, scheduler, or worker, and is disabled by default.

Why it's needed

An escaped Shell descendant can keep writing after the registered worker or its process group exits. An incomplete capture correctly blocks the original Hosted turn and the Workspace lease, but without an explicit recovery path every later Session on that Workspace remains blocked. This procedure preserves the uncertain result while giving an operator who has verified all writers are stopped a narrow, auditable way to reclaim the original holder.

Reviewer Test Plan

How to verify

On a Linux durable worker, produce a producer_lost Shell capture with a detached writer and confirm that a second Session stays busy. Inspect and prepare the exact holder; verify that ordinary release, new admission, placement replacement, and background recovery cannot bypass the fence. Withhold or alter the statement, leave the registered worker running, or provide the wrong holder; completion must refuse and preserve the lease. After stopping every writer and preventing restarts, complete with the private statement and confirm a new Session can use the Workspace, while the original turn remains blocked and the old Shell command is not replayed. Retry the same statement after interrupted cleanup and confirm that a changed statement or stale holder cannot release a newer holder.

Evidence (Before & After)

Before: a saved partial producer_lost Shell result pins the Workspace even after the worker exits. After: only explicit preparation plus an immutable operator statement and exact registered-worker exit permit retirement of the original generation; its partial output and uncertain effects remain preserved.

Tested on

OS Status
🍏 macOS ✅ Java H2 and MySQL contracts, real-worker fault gate, build, typecheck, bundle
🪟 Windows N/A: Linux-only durable worker recovery
🐧 Linux ✅ Maintainer passed 34/34 physical operator-runbook checks and 41/41 fault gates on 0f8ad6daf0; the GitHub fault-gate job also passed

Environment (optional)

Java 21, real MySQL 8.4, and a source-built Broker and worker. The maintainer re-verified the physical Linux operator runbook on 0f8ad6daf0: 34/34 steps passed with a real escaped writer, fencing, refusal while the worker lived, and Workspace reuse. The saved v3 partial outcome was seeded through the production repository path rather than a full model turn. On the same head, 41/41 physical Linux fault gates, 464/464 Broker tests, 190/190 service H2 tests, 10/10 Hosted MySQL failsafe tests, and 15/15 MySQL integration tests passed; the GitHub fault-gate job also passed. Local Checkstyle, build, and typecheck passed.

Risk & Scope

  • Main risk or tradeoff: Software verifies the registered worker identity and exact SQL holder, but only the operator can attest that arbitrary detached writers and restart sources are gone.
  • Not validated / out of scope: The maintainer verified physical Linux completion on this head, but not a full model-driven v3 turn or a physical Broker restart after prepare. The Linux fault-gate regression was traced to test-only H2 2.3.232; this head upgrades it to 2.4.240, and both physical fault gates and GitHub CI passed. This does not resume the original Hosted turn, replay Shell, or add a public API.
  • Breaking changes / migration notes: The service adds one internal audit table and ships a separate maintenance artifact. The procedure is disabled by default and requires the original durable registration; legacy ephemeral workers are unsupported.

Linked Issues

Related to #12904. Builds on the merged #12869 and targets main.

中文说明

本 PR 的改动

新增默认关闭的离线 workspace-recovery inspect|prepare|complete 流程,处理 Linux durable Hosted worker 因 Shell 捕获不完整而占住 Workspace 租约的情况。准备阶段记录精确 holder 和原执行,并封禁 binding,但不释放 Workspace。完成阶段要求私有运维证明、核实已注册 worker 退出、封存其持久身份,再复用已有的精确 holder 清理流程,让新 Session 可以使用 Workspace。原回合和结果不确定的 Shell 调用仍被阻断,绝不重放。

同时新增持久审计记录、同步的英文设计与中文设计,以及英文手册和中文手册。维护命令不启动 HTTP 服务、调度器或 worker,且默认关闭。

需求原因

逃逸的 Shell 子进程可能在已注册 worker 或其进程组退出后继续写入。不完整捕获正确地阻断了原 Hosted 回合和 Workspace 租约,但目前同一 Workspace 的后续 Session 会一直被阻断。本流程保留不确定的结果,同时让已核实全部写入者停止的运维人员能够以范围明确、可审计的方式回收原 holder。

评审验证计划

验证方式

在 Linux durable worker 上制造带有逃逸写入者的 producer_lost Shell 捕获,确认第二个 Session 仍收到忙碌拒绝。检查并准备精确 holder,确认普通释放、新准入、placement 替换和后台恢复均不能绕过围栏。缺少或改动证明、原 worker 仍运行、或 holder 错误时,完成操作必须拒绝并保留租约。停止全部写入者并阻止重启后,用私有证明完成恢复,确认新 Session 可使用 Workspace,而原回合仍被阻断、旧 Shell 命令没有重放。中断清理后用同一证明重试,确认更改证明或过期 holder 不能释放新的 holder。

前后证据

之前:已保存的 producer_lost 部分 Shell 结果即使在 worker 退出后仍占住 Workspace。之后:只有明确准备、不可覆盖的运维证明,以及对原注册 worker 退出的核实,才能退休原代数;部分输出与不确定副作用仍保留。

已测试平台

系统 状态
macOS ✅ Java H2/MySQL 合约、真实 worker 故障门禁、build、typecheck、bundle
Windows 不适用:仅支持 Linux durable worker 恢复
Linux ✅ 维护者在 0f8ad6daf0 上完成 34/34 项物理运维手册验证及 41/41 项故障门禁;GitHub 故障门禁作业也通过

环境

Java 21、真实 MySQL 8.4、源码构建的 Broker 与 worker。维护者在 0f8ad6daf0 上复验 Linux 物理运维流程:真实逃逸写入者、围栏、worker 存活时拒绝完成及 Workspace 复用等 34/34 项通过。保存的 v3 部分结果通过生产仓储路径写入,并非完整模型回合。同一提交通过 41/41 项物理 Linux 故障门禁、464/464 项 Broker 测试、190/190 项服务 H2 测试、10/10 项 Hosted MySQL failsafe 测试及 15/15 项 MySQL 集成测试;GitHub 故障门禁作业也通过。本机 Checkstyle、build 和 typecheck 通过。

风险与范围

  • 主要风险或权衡:软件可以核实已注册 worker 身份及精确 SQL holder;任意逃逸写入者和重启来源是否均已停止,仍由运维人员证明。
  • 未验证或不在范围:维护者已在本提交上验证 Linux 物理完成流程,但尚未验证完整模型驱动的 v3 回合或 prepare 后 Broker 物理重启。Linux 故障门禁回归被定位到仅用于测试的 H2 2.3.232;本提交升级至 2.4.240,物理门禁和 GitHub CI 均通过。本 PR 不恢复原 Hosted 回合、不重放 Shell,也不新增公共 API。
  • 兼容与迁移:服务新增一张内部审计表,并提供独立维护制品。流程默认关闭,要求原有 durable 注册记录,不支持旧临时 worker。

关联

关联 #12904;基于已合并的 #12869,目标分支为 main。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

E2E verification report for #12904

  • Baseline reproduction: using the source-built Broker/worker and a real escaped Shell descendant on macOS, the marker continued changing after the registered worker and its process group exited. The original partial producer_lost result and Workspace lease remained the basis for the recovery scenario. The installed qwen 0.24.6 cannot exercise this Hosted profile.
  • Portable post-change verification: the real-worker fault gate passed in both durable and legacy modes (2/2). The runtime-broker and managed-agent-server H2 suites passed. A local MySQL 8.4 instance passed the service integration contract, the Workspace recovery test, and Broker integration tests in fresh schemas. The tests cover exact holder fencing, preserved settled output, immutable attestation, safe cleanup and retirement, stale callback rejection, and later use of the same Workspace.
  • Build checks: Java package and Checkstyle, npm run build, npm run typecheck, and npm run bundle passed. The separate maintenance JAR was built and its entry point verified. On this macOS host, enabling its Linux-only durable mode correctly refused startup.
  • Remaining physical acceptance: run inspect, prepare, and complete against a Linux durable worker with a real detached writer, then confirm a second Session succeeds only after operator-verified quiescence. Exercise MariaDB migration and contract execution in CI. This draft does not claim that portable tests prove arbitrary detached writers have stopped; that remains an operator attestation.

@wenshao

wenshao commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Real-environment verification of #12977 at aeaf0ade85

Verdict: not ready to merge yet (it is still a draft). On a real Linux durable worker, the runbook's first command refuses the real Hosted Shell record that the PR is for (F1), so the procedure cannot start. The cause is one filter. With a 5-line fix, the whole runbook works end to end on the real stack. Everything else I checked holds: fencing, statement checks, crash retry, no replay, preserved output, and CI replay on MariaDB and MySQL. Two more items to settle before merge: a reboot between prepare and complete leaves the Workspace with no supported way out (F2), and the V19 migration collides with open #12894 (F3).

Environment

  • VM: Ubuntu 24.04.4 aarch64, kernel 6.8 (dedicated colima vz VM with 4 vCPU and 8 GB, so it could be rebooted for real).
  • Service: the PR's server jar ran as a systemd service (KillMode=process) with the embedded Broker. Settings: durable-local-process=true, trusted reboot recovery off (on for S4), JDK 21.0.9, Node 22.23.2, MySQL 8.4.11 and MariaDB 10.11.18 in containers.
  • Hosted chain: packaged Hosted Harness with tool profile hosted-workspace-shell/1, a scripted OpenAI-compatible model, real Broker and worker processes, and the SQL Store.
  • Bundle: built from 55ded4c3. The TypeScript is identical to the PR (git diff 55ded4c3 aeaf0ade85 -- . ':!packages/sdk-java' ':!docs' is empty).
  • Incident: a real Shell turn runs setsid sh -c 'while :; do echo … >> escaped-marker; sleep 0.5; done' & echo started. The descendant keeps the capture pipe, escapes the worker's process group and session (ppid 1), and keeps writing.
  • Jars: both the service jar and the -operator-recovery.jar were built from the PR head in a Linux Maven container.

F1 (blocking): inspect and prepare refuse every real Hosted Shell record

F1

  • WorkspaceOperatorRecoveryStore.capture() keeps a row only if reference_json.toolName == "run_shell_command".
  • A Hosted Shell call is created through prepareExecution (protocol 3, deferred dispatch). Its stored reference has no toolName. The actual keys are sessionId, promptId, callId, argsDigest, runtimeProtocol=3, inputDigest, dispatchMode="deferred". startExecution already enforces that v3 payloads are run_shell_command.
  • Real stack (S0):
    • The Session produced SETTLED partial/producer_lost and held the Workspace. The second Session got 409 workspace_busy.
    • inspect <binding> 1 failed with Exact Hosted Shell operator recovery is unavailable.
    • prepare with the exact holder_key from SQL failed the same way. The binding stayed READY and no audit row was written.
  • Root-cause check on a live record: after JSON_SET(reference_json,'$.toolName','run_shell_command'), inspect returned eligibleForPrepare:true. After JSON_REMOVE, it was refused again.
  • Why the tests pass: WorkspaceRuntimeTest and WorkspaceRecoveryContract build the reference by hand with toolName. I used the same test body with the production v3 reference: it errors on the PR head (15 run, 1 error) and passes 15/15 with the fix below. Checkstyle reports 0 violations.

Candidate fix (the test is in the evidence branch as cand-capture.patch):

-                        if (!"run_shell_command".equals(reference.path("toolName").asText())
+                        // Protocol-3 deferred references carry no toolName; startExecution
+                        // admits only run_shell_command payloads for them.
+                        boolean shell = "run_shell_command".equals(reference.path("toolName").asText())
+                                || reference.path("runtimeProtocol").asInt() == 3
+                                        && "deferred".equals(reference.path("dispatchMode").asText());
+                        if (!shell
                                 || "complete".equals(result.path("capture").path("captureStatus").asText())) {

With that fix, the runbook works end to end (S1; service = PR head jar, unchanged)

S1

  • inspect: returns the holder, execution, partial/producer_lost and eligibleForPrepare:true. A wrong generation is refused.
  • prepare:
    • A wrong holder is refused, and nothing is written.
    • The correct holder moves the binding from READY to OPERATOR_RECOVERY, keeps the holder, and writes one audit row.
    • Repeating it returns the same recoveryId. A different reason is refused.
  • Fence while prepared:
    • Same Workspace: 409 workspace_busy.
    • Original Session: 409 hosted_turn_recovery_required.
    • New Session in the same Workspace: warm 409 runtime_placement_recovery_required.
    • Another Workspace is unaffected.
  • Refusals from complete: a missing file, mode 0644, a symlink, a file outside the state directory, a different recoveryId, restartPrevention:false, and a valid statement while the registered worker is alive. None of them persisted anything.
  • Operator step:
    • After kill -9 of the worker, the marker kept growing (111 → 115 lines in 2 s); the software cannot see the escaped writer.
    • A scan of /proc/*/cwd found it; I killed it, and the marker stayed at 115 for 5 s.
    • complete then succeeded in 4.4 s:
      • The binding became RELEASED with loss=registered-process-exit and stop=operator-attested:<id>.
      • The holder was cleared, the registration became RETIRED, and the audit row was marked attested and completed.
      • The execution row is still SETTLED partial/producer_lost.
  • After completion:
    • The second Session succeeds; the original Session is still 409 (as designed).
    • The ESCAPE command ran once in total; it was never replayed.
    • Retrying with the same file prints completed. A changed statement is refused, and so is prepare.
    • Running the old complete while a new holder is mid-call leaves that holder untouched.
  • S3 (crash, fix applied): I held the holder-clear UPDATE and, separately, the final RELEASED update with a MySQL trigger (SLEEP(25)), and sent SIGKILL to the maintenance JVM while it waited.
    • In both cases the state stayed consistent: LOST plus stop evidence, with the audit row attested but not completed.
    • One retry with the same file, after the 30 s claim expired, completed the recovery.
  • S5/S5b (worker already dead before the operator arrives, with and without a service restart): the binding stays READY (not LOST), and the procedure completes. So on this stack the "already LOST" branch of prepare is covered only by the unit test.
  • S6 (base 62584d31, before this PR): the same incident stays pinned after every writer is killed and the service is restarted, and there is no tool to recover it. This confirms the motivation.

F2: a reboot between prepare and complete leaves the Workspace with no supported way out

F2

Setup: service with trusted reboot recovery on, two identical incidents, prepare on ws-c only, then a real systemctl reboot. The boot changed and the service was back in 33 s.

  • ws-d (not prepared): the first W0e-3 scan released it (trusted-host-reboot), and a new Session works.
  • ws-c (prepared): after 12 minutes, the last 2 of them under a freshly restarted trusted service, it was still OPERATOR_RECOVERY (record version 14, unchanged) and the holder was still held.
    • inspect, complete and prepare all fail with Local Runtime identity cannot be verified. because the registration is from the previous boot.
    • A new Session fails with 409 runtime_placement_recovery_required.

findRecoveryCandidates never selects OPERATOR_RECOVERY and also skips generations with an open audit row. Meanwhile attestOperatorStop and verifyOperatorRegistration require the same boot. A reboot is the most reliable way to stop detached writers, and hosts crash, so this is a likely path. It leads to a state that only manual SQL can clear, which the runbook forbids.

Suggestion: let complete accept a registration from an earlier boot of the same host (the reboot is the stop evidence), or let the trusted scan take over a prepared generation after a reboot and close its audit row.

F3 (merge order): V19 collides with open #12894

#12894 adds V19__managed_tool_publication.sql and V20__…; #12946 has already moved to V21. Git reports no conflict. I started the PR's jar with #12894's V19 file added to the classpath: startup failed with Found more than one migration with version 19. Without that file (control), 19 migrations were applied and the server started in 9 s. Whichever PR lands second needs to renumber (V22 would clear both).

CI replay (no Java CI has run on this head because the base is stacked)

CI replay, V19 and mutation

I replayed the Java 21 jobs in sdk-java.yml step by step in a Linux container, each against a fresh database:

Job / step Result at aeaf0ade85
Runtime Broker state tests + Checkstyle 431 run, 0 failures, 2 skipped; DurableLocalProcessRuntimeProvisionerTest 31/31, not skipped on Linux
MariaDB 10.11.18: Broker MySQL IT JdbcRuntimeBrokerMySqlIT 3/3
MariaDB: Managed Agent tests + Checkstyle + MySQL IT green; ManagedAgentMySqlIT 15/15 (includes the new verifyOperatorPrepare and the V19 migration)
non-Hosted report guard pass
MySQL 8.4.11: Hosted Java, Spring and MySQL processes + Checkstyle green; HostedWorkspaceToolTurnIT 5/5, HostedHarnessMySqlIT 2/2, HostedProcessCrashIT 1/1, HostedWorkspaceRecoveryWorkerIT 1/1
Hosted report guard pass
Runtime Broker fault gates 41/41

This covers the "MariaDB CI execution" the PR description lists as pending. The Hosted job needs node_modules in the tree for its drivers, so I built it from the 55ded4c3 checkout with the Linux esbuild binary added.

One more run showed 10 durable fault-gate failures (409 runtime_admission_closed). That was a rig artifact: those gates ran in place on the macOS-backed virtiofs mount. The same gates from the container's local disk passed 41/41 in two runs.

Mutation check of the new safety checks

I ran 12 single-site mutants of the new checks against the Broker and server unit suites. BASE was green, and a kill had to fail twice.

  • Killed (5):

    • M01: attestOperatorStop accepts a live worker; killed by DurableLocalProcessRuntimeProvisionerTest.
    • M02: the scan stops skipping prepared generations.
    • M05: prepare ignores the holder.
    • M06: attest accepts a changed statement.
    • M08: evidence may be world-readable.
  • Survive unit tests and the MySQL IT (MariaDB/MySQL jobs):

    • M03: OPERATOR_RECOVERY no longer blocks placement.
    • M04: OPERATOR_RECOVERY may change to any state.

    WorkspaceRuntimeTest takes the "already LOST" branch. The MySQL contract does reach OPERATOR_RECOVERY, but it asserts only the state and that release is refused. So the path that the real stack takes (S1) has no placement or state-transition assertion. On the live stack the Workspace holder and ensureBinding's runtime_binding_unavailable branch still cover it.

  • Survive in-tree:

    • M07: any partial capture reason qualifies.
    • M09: evidence outside the state directory, and M12: restartPrevention optional. S1 exercised both on the real stack, and both were refused.
    • M10: complete skips requireHeld, and M11: prepare skips the registration preflight. WorkspaceRecoveryCommand.main has no test; WorkspaceRuntimeTest re-implements its steps by hand.

Non-blocking notes

  1. Operator identity.
    • The command works only as the service's OS account. Running it as root fails with Recovery entry is not private to its owner, and so does -Duser.name=alice.
    • As a result, operator_id is always the service account.
    • A different person (SUDO_USER=bob) repeating prepare gets the same recoveryId, so the documented "different operator is rejected" never distinguishes people.
    • Suggestion: state in the runbook which account to use, and record SUDO_USER/loginuid or an explicit --operator.
  2. verifiedAt has no lower bound. A statement dated 2020-01-01T00:00:00Z (before the incident and before prepare) was accepted (S5). Suggestion: check verifiedAt >= prepared_at.
  3. Output and messages.
    • The result JSON shares stdout with the Spring banner and INFO logs (about 16 lines), so inspect | jq breaks.
    • Every refusal is a stack trace. With the worker still alive, the message is Local Runtime identity cannot be verified., which does not tell the operator what to do next.
  4. Packaging. The operator-recovery jar is repackaged from the already repackaged main jar, so it carries the Boot loader a second time under BOOT-INF/classes/org/springframework/boot/loader/** (+216 KB). It works; this is cosmetic.
  5. Not covered here: x86_64 hosts, a real model (the model was scripted), and an end-to-end run on MariaDB (only the IT job).

Evidence (scripts, logs, CI replay summaries, mutation logs, candidate patch): wenshao/qwen-code@44c96a9e69:pr12977/. A trial merge of this head with the current main d5a157c45e is clean, and main has not added a V19.

中文版

结论:暂不可合并(本身仍是 draft)。 在真实的 Linux durable worker 上,运维手册的第一条命令就会拒绝本 PR 要处理的那种真实 Hosted Shell 记录(F1),整个流程无法开始。原因是一处过滤条件。加上 5 行修复后,整套手册在真实栈上可以端到端走通。其余检查项全部成立:围栏、证明文件校验、崩溃后重试、不重放、保留原输出,以及在 MariaDB/MySQL 上重放 CI。合并前还有两件事要定:prepare 与 complete 之间若发生重启,Workspace 没有任何受支持的出路(F2);V19 迁移与开放中的 #12894 撞号(F3)。

环境

  • VM:Ubuntu 24.04.4 aarch64,内核 6.8(专用 colima vz VM,4 vCPU / 8 GB,可以真实重启)。
  • 服务:PR 的 server jar 以 systemd 服务运行(KillMode=process),内嵌 Broker。配置为 durable-local-process=true,可信重启恢复默认关(S4 打开);JDK 21.0.9、Node 22.23.2;MySQL 8.4.11 与 MariaDB 10.11.18 跑在容器里。
  • Hosted 链路:打包的 Hosted Harness(工具档 hosted-workspace-shell/1)、脚本化的 OpenAI 兼容模型、真实 Broker/worker 进程、SQL Store。
  • Bundle:基于 55ded4c3 构建,TypeScript 与 PR 完全相同(git diff 55ded4c3 aeaf0ade85 -- . ':!packages/sdk-java' ':!docs' 为空)。
  • 事故制造:真实 Shell 回合执行 setsid sh -c 'while :; do echo … >> escaped-marker; sleep 0.5; done' & echo started。这个后代进程持有捕获管道,逃出 worker 的进程组和会话(ppid 1),并持续写入。
  • 构建:服务 jar 与 -operator-recovery.jar 都在 Linux Maven 容器里从 PR head 构建。

F1(阻塞):inspect/prepare 拒绝所有真实 Hosted Shell 记录

F1

  • WorkspaceOperatorRecoveryStore.capture() 只保留 reference_json.toolName == "run_shell_command" 的行。
  • Hosted Shell 调用走 prepareExecution(协议 3、延迟派发),存下来的 reference 没有 toolName。实际字段是 sessionId, promptId, callId, argsDigest, runtimeProtocol=3, inputDigest, dispatchMode="deferred"。startExecution 已经强制规定协议 3 的载荷只能是 run_shell_command。
  • 真实栈 S0:
    • 原 Session 产生 SETTLED partial/producer_lost 并占住 Workspace,第二个 Session 得到 409 workspace_busy。
    • inspect <binding> 1 失败:Exact Hosted Shell operator recovery is unavailable.
    • 用 SQL 里取到的精确 holder_key 执行 prepare,同样失败。binding 仍为 READY,审计表为空。
  • 在真实记录上确认根因:JSON_SET(reference_json,'$.toolName','run_shell_command') 之后,inspect 返回 eligibleForPrepare:true;JSON_REMOVE 之后再次被拒。
  • 测试为什么没发现:WorkspaceRuntimeTest 与 WorkspaceRecoveryContract 手工构造 reference 时带了 toolName。我用同样的测试体、换成生产的 v3 reference:在 PR head 上报错(15 个中 1 个 error),加下面的修复后 15/15,checkstyle 0 违规。

候选修复(测试见证据分支中的 cand-capture.patch):

-                        if (!"run_shell_command".equals(reference.path("toolName").asText())
+                        // Protocol-3 deferred references carry no toolName; startExecution
+                        // admits only run_shell_command payloads for them.
+                        boolean shell = "run_shell_command".equals(reference.path("toolName").asText())
+                                || reference.path("runtimeProtocol").asInt() == 3
+                                        && "deferred".equals(reference.path("dispatchMode").asText());
+                        if (!shell
                                 || "complete".equals(result.path("capture").path("captureStatus").asText())) {

加上该修复后,手册端到端可行(S1;服务仍是未改动的 PR head jar)

S1

  • inspect:返回 holder、执行记录、partial/producer_lost 和 eligibleForPrepare:true。代数写错时拒绝。
  • prepare:
    • holder 写错时拒绝,且不写入任何东西。
    • holder 正确时,binding 从 READY 变为 OPERATOR_RECOVERY,holder 保留,写入一条审计行。
    • 重复执行返回同一个 recoveryId;换一个 reason 则拒绝。
  • prepare 之后的围栏:
    • 同一 Workspace:409 workspace_busy。
    • 原 Session:409 hosted_turn_recovery_required。
    • 同一 Workspace 的新 Session:warm 409 runtime_placement_recovery_required。
    • 其它 Workspace 不受影响。
  • complete 的拒绝情形:文件缺失、0644 权限、符号链接、文件在 state 目录之外、recoveryId 不一致、restartPrevention:false,以及已注册 worker 仍存活时提交有效证明。以上都没有写入任何东西。
  • 运维步骤:
    • kill -9 杀掉 worker 后,marker 仍在增长(2 s 内 111 → 115 行),说明软件看不到逃逸写入者。
    • 扫描 /proc/*/cwd 找到该进程并杀掉,marker 在 5 s 内保持 115 行不变。
    • 之后 complete 用 4.4 s 完成:
      • binding 变为 RELEASED,带 loss=registered-process-exit 与 stop=operator-attested:<id>。
      • holder 被清空,注册文件变为 RETIRED,审计行标记为已证明、已完成。
      • 执行记录仍是 SETTLED partial/producer_lost。
  • 完成之后:
    • 第二个 Session 成功;原 Session 仍返回 409(符合设计)。
    • ESCAPE 命令总共只执行过一次,没有被重放。
    • 用同一文件重试输出 completed;改过的证明被拒,再次 prepare 也被拒。
    • 新 holder 正在执行命令时运行旧的 complete,新 holder 不受影响。
  • S3(崩溃,已带修复):用 MySQL 触发器(SLEEP(25))分别挂起清 holder 的 UPDATE 和最终改为 RELEASED 的更新,在维护 JVM 等待时对它发 SIGKILL。
    • 两处崩溃后状态都保持一致:LOST 加停写证据,审计行已证明、未完成。
    • 等 30 s 认领过期后,用同一文件重试一次即完成恢复。
  • S5/S5b(运维介入前 worker 已死,分别测试了是否重启服务):binding 一直是 READY 而不是 LOST,流程可以完成。所以在这个栈上,prepare 里“已是 LOST”的分支只有单测覆盖。
  • S6(base 62584d31,即本 PR 之前):同样的事故在杀掉所有写入者、重启服务之后仍被钉住,也没有任何工具可用。这印证了本 PR 的动机。

F2:prepare 与 complete 之间重启,Workspace 没有受支持的出路

F2

做法:打开可信重启恢复,制造两个相同的事故,只对 ws-c 执行 prepare,然后真实执行 systemctl reboot。boot 已变更,服务 33 s 后恢复。

  • ws-d(未 prepare):开机后第一轮 W0e-3 扫描就把它释放了(trusted-host-reboot),新 Session 可用。
  • ws-c(已 prepare):12 分钟后(其中最后 2 分钟处于重新启动的可信服务之下)仍是 OPERATOR_RECOVERY(记录版本 14,未变),holder 仍被占住。
    • inspect、complete、prepare 都因为注册文件属于上一次启动而失败:Local Runtime identity cannot be verified.
    • 新 Session 失败:409 runtime_placement_recovery_required。

findRecoveryCandidates 从不选 OPERATOR_RECOVERY,还会跳过带未完成审计行的代数;而 attestOperatorStop/verifyOperatorRegistration 要求同一次启动。重启是停止逃逸写入者最可靠的方式,宿主机也会意外崩溃,所以这条路径很容易走到。走到之后,只有手工改 SQL 才能解开,而手册明确禁止这样做。

建议:允许 complete 接受同一宿主上一次启动的注册文件(重启本身就是停写证据),或者让可信扫描在重启后接管已 prepare 的代数,并关闭其审计行。

F3(合并顺序):V19 与开放中的 #12894 撞号

#12894 新增了 V19__managed_tool_publication.sql 和 V20__…;#12946 已经改用 V21。git 不会报冲突。我把 #12894 的 V19 文件加进 PR jar 的 classpath 后启动,报错 Found more than one migration with version 19;对照组(不加这个文件)应用了 19 个迁移,9 s 内正常启动。后合入的那个 PR 需要改号(用 V22 可同时避开两者)。

CI 重放(该 head 因 base 是堆叠分支,没有跑过任何 Java CI)

CI 重放、V19 与变异

在 Linux 容器里按 sdk-java.yml 的 Java 21 作业逐步重放,每个作业都用新建的数据库:

作业/步骤 aeaf0ade85 结果
Runtime Broker state tests + Checkstyle 431 个,0 失败,2 跳过;DurableLocalProcessRuntimeProvisionerTest 31/31,在 Linux 上未跳过
MariaDB 10.11.18:Broker MySQL IT JdbcRuntimeBrokerMySqlIT 3/3
MariaDB:Managed Agent 测试 + Checkstyle + MySQL IT 通过;ManagedAgentMySqlIT 15/15(含新的 verifyOperatorPrepare 与 V19 迁移)
非 Hosted 报告守卫 通过
MySQL 8.4.11:Hosted Java/Spring/MySQL + Checkstyle 通过;HostedWorkspaceToolTurnIT 5/5、HostedHarnessMySqlIT 2/2、HostedProcessCrashIT 1/1、HostedWorkspaceRecoveryWorkerIT 1/1
Hosted 报告守卫 通过
Runtime Broker 故障门禁 41/41

这覆盖了 PR 描述里列为待办的 “MariaDB CI 执行”。Hosted 作业的驱动需要树里有 node_modules,所以我用 55ded4c3 的检出构建,并补上了 Linux 版 esbuild 二进制。

另有一轮出现 10 个 durable 门禁失败(409 runtime_admission_closed),这是装置问题:那一轮在 macOS 提供的 virtiofs 挂载目录里原地运行。同样的门禁在容器本地盘上跑了两次,都是 41/41。

新安全检查的变异验证

对新增检查做了 12 个单点变异,跑 Broker 与 server 单测。BASE 全绿,判定“被杀”需要连续失败两次。

  • 被杀 5 个:

    • M01:attestOperatorStop 接受存活 worker,被 DurableLocalProcessRuntimeProvisionerTest 杀死。
    • M02:扫描不再跳过已 prepare 的代数。
    • M05:prepare 不校验 holder。
    • M06:attest 接受被改过的证明。
    • M08:证明文件可被他人读取。
  • 单测和 MySQL IT 都存活:

    • M03:OPERATOR_RECOVERY 不再阻止放置。
    • M04:OPERATOR_RECOVERY 可转为任意状态。

    WorkspaceRuntimeTest 走的是“已是 LOST”分支;MySQL 契约测试虽然到达了 OPERATOR_RECOVERY,但只断言了状态和 release 被拒。因此真实栈实际走的路径(S1)没有针对放置和状态转换的断言。在真实栈上,Workspace holder 与 ensureBinding 的 runtime_binding_unavailable 分支仍能兜底。

  • 树内存活:

    • M07:任意 partial 原因都算。
    • M09:证明文件在 state 目录外;M12:restartPrevention 可省略。两者在 S1 的真实栈上都会被拒绝。
    • M10:complete 跳过 requireHeld;M11:prepare 跳过注册预检。WorkspaceRecoveryCommand.main 没有测试,WorkspaceRuntimeTest 是手工重写了它的步骤。

非阻塞建议

  1. 操作员身份:
    • 命令只能以服务的 OS 账号运行。以 root 运行报 Recovery entry is not private to its owner,加 -Duser.name=alice 也同样失败。
    • 因此 operator_id 永远是服务账号。
    • 换一个人(SUDO_USER=bob)重复 prepare,拿到的是同一个 recoveryId,文档里“不同操作员会被拒绝”实际上区分不了人。
    • 建议在手册里写明要用哪个账号运行,并记录 SUDO_USER/loginuid,或增加显式的 --operator 参数。
  2. verifiedAt 没有下界:日期为 2020-01-01T00:00:00Z(早于事故,也早于 prepare)的证明被接受了(S5)。建议校验 verifiedAt >= prepared_at。
  3. 输出与提示:
    • 结果 JSON 与 Spring 横幅、INFO 日志一起输出到 stdout(约 16 行),inspect | jq 会失败。
    • 所有拒绝都以堆栈形式输出;worker 仍存活时提示的是 Local Runtime identity cannot be verified.,运维无法据此判断下一步该做什么。
  4. 打包:operator-recovery jar 是在已经 repackage 过的主 jar 上再次 repackage 出来的,BOOT-INF/classes/org/springframework/boot/loader/** 下多带了一份 Boot loader(+216 KB)。能正常运行,仅是外观问题。
  5. 本次未覆盖:x86_64 宿主、真实模型(本次模型是脚本化的)、MariaDB 上的端到端(只跑了 IT 作业)。

证据(脚本、日志、CI 重放摘要、变异日志、候选补丁):wenshao/qwen-code@44c96a9e69:pr12977/。本 head 与当前 main d5a157c45e 试合并无冲突,main 也没有新增 V19。

@doudouOUC
doudouOUC changed the base branch from codex/managed-workspace-w0e-reboot to main September 29, 2026 13:18
@doudouOUC
doudouOUC requested review from qwen-code-ci-bot and wenshao and removed request for wenshao September 29, 2026 13:24
@doudouOUC doudouOUC self-assigned this Sep 29, 2026
@doudouOUC
doudouOUC marked this pull request as ready for review September 29, 2026 13:24
@doudouOUC
doudouOUC enabled auto-merge September 29, 2026 13:24
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks @wenshao for the real Linux verification. Addressed in d73a762e6:

Finding Action
F1: real Hosted Shell records lack toolName Fixed. Recovery now recognizes protocol-3 deferred Shell references; the H2 and SQL recovery contracts use the production reference shape.
F2: a reboot after prepare strands the operator fence Fixed. The explicit complete path accepts the exact saved registration after a reboot of the same host, while keeping the holder fenced until operator proof is submitted. Same-boot completion still requires the original process to be absent; a different host remains rejected. The bilingual design and runbook now cover this path.
F3: migration version overlap with open #12894 Merge-order gate remains. Current main has no V19–V22 migration, while #12894 and #12946 remain open. Changing this PR's V19 now would create an out-of-order migration if this PR lands first. I will recheck the order before merge and renumber/rebase if either dependency lands first.
Operator identity note Corrected the runbook: operator_id identifies the service OS account; the human operator must be recorded in the incident record.

The timestamp lower bound, console output, and packaging suggestions are deferred from this focused correctness fix; the operator statement remains a human trust boundary. Local verification passed: durable worker tests 34/34, Hosted recovery tests 15/15, Java Checkstyle, repository build, and typecheck. The new reboot branch was tested with a synthetic same-host boot transition; physical Linux reboot retest of this commit is still pending. There are no unresolved inline review threads to resolve.

@doudouOUC doudouOUC changed the title fix(sdk-java): Add audited Hosted Workspace operator recovery feat(sdk-java): Add audited Hosted Workspace operator recovery Sep 29, 2026
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

[codex] Follow-up to @wenshao's Linux verification and the CI bot's Stage 1–3 review. The focused fixes are pushed as 5e3484b28.

Feedback Resolution
F7: a statement dated before prepare could be accepted Fixed. complete now compares verifiedAt with the persisted preparation time. The audit timestamps are written/read with explicit UTC semantics; an older statement is rejected.
F8: the operator fence and placement protections lacked direct assertions Fixed. The H2/MySQL contract now checks that an OPERATOR_RECOVERY binding cannot be changed back to READY, reused through either the storage ID or directory, or selected by automatic recovery.
F4: Broker scan reads the operator audit table Intentional fail-closed coupling. Both the server migration and standalone Broker schema create the table; without it, the scan cannot safely ignore a prepared generation. The bilingual design now states this dependency.
F5: managed LOST placement also blocks the same storage/directory before opt-in Intentional safety rule. An uncertain physical writer must not become reachable again by renaming the Workspace; the bilingual design now makes that behavior explicit.
F3: Flyway V19 collides with open #12894 Still a merge-order gate. #12894 and #12946 remain open, and main has not acquired their migrations. Renumbering now could make this PR's migration out of order if it merges first. I will rebase and renumber if they land first.
Alternative automatic scan architecture and operator identity/CLI presentation suggestions Keeping the separate local maintenance command: explicit human writer-stop evidence cannot be supplied by the automatic reboot scan. The remaining presentation and identity suggestions are non-blocking follow-ups outside this safety fix.

The PR title/body now describe a feature and distinguish the earlier physical Linux runbook exercise from a retest of the current head. Local targeted Java tests, Checkstyle, project build and typecheck pass. The prior MySQL fault-gate failure happened at initial admission before fault injection; it also reproduced intermittently in a focused local run. The new head's CI will determine whether a bounded rerun is needed. No inline review threads were open (0/0 resolved).

@wenshao wenshao 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.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — the Hosted process fault gates were not run locally (they spawn real processes and need a built CLI); the CI job that runs them is red at this head.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": the two new DurableLocalProcessRuntimeProvisionerTest cases and my fix witness were not compiled or run (no build in this tree), so their red state under muta…; "agent 1c": dynamic resolution of the new second repackage execution** — the operator-recovery execution's source artifact ( AbstractPackagerMojo.getSourceArtifact fal…; "agent reverse-audit (round 3)": did not read RuntimeBrokerService.recoverBinding 's release decision (~1600-1730) or the on-demand reclaim path, so how those non-scan consumers treat the new …; "agent reverse-audit (round 3)": the module's own classified jar could not be built or launched — review rules forbid running the PR's build logic — so the dependency-nesting half of the double…; "agent reverse-audit (round 2)": MainClassFinder behaviour — whether an un-annotated second main class is a candidate for Spring Boot's single-main-class search; my probe could not reach the ….

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — the Hosted process fault gates were not run locally (they spawn real processes and need a built CLI); the CI job that runs them is red at this head.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":the two new DurableLocalProcessRuntimeProvisionerTest cases and my fix witness were not compiled or run (no build in this tree), so their red state under muta…;"agent 1c":dynamic resolution of the new second repackage execution** — the operator-recovery execution's source artifact ( AbstractPackagerMojo.getSourceArtifact fal…;"agent reverse-audit (round 3)":did not read RuntimeBrokerService.recoverBinding 's release decision (~1600-1730) or the on-demand reclaim path, so how those non-scan consumers treat the new …;"agent reverse-audit (round 3)":the module's own classified jar could not be built or launched — review rules forbid running the PR's build logic — so the dependency-nesting half of the double…;"agent reverse-audit (round 2)":MainClassFinder behaviour — whether an un-annotated second main class is a candidate for Spring Boot's single-main-class search; my probe could not reach the …。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.6)

Comment thread docs/design/2026-09-29-hosted-operator-recovery.md Outdated
Comment thread docs/users/hosted-workspace-recovery.md Outdated
Comment thread docs/users/hosted-workspace-recovery.md Outdated
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the new review round in 84ac51816.

Action Review items Result
Fixed R1-1, R1-16 prepare now shares the tenant placement guard with runtime placement; unreclaimed RECOVERY_BLOCKED and FAILED bindings block alias placement on the same storage/directory. Independent before/after probes confirmed both defects and fixes.
Fixed R1-26, R1-2, R1-27, R1-11, R1-8, R1-17, R1-9 Evidence is opened without following symlinks, read once with an 8 KiB bound, and required to be byte-exact UTF-8 with an exact integer schema version. Validator regressions cover size, time, version, restart prevention and outside paths; the runbooks state those bounds.
Fixed R1-3, R1-5, R1-6, R1-7, R1-22, R1-25 Added meaningful fence, scan and snapshot contract cases; removed two vacuous LOST assertions. The shared contract also covers DRAINING and RECOVERY_BLOCKED preparation.
Fixed R1-12, R1-13, R1-14 Updated the bilingual runbooks and state/design documents.
Deferred acceptance R1-4, R1-15 Full command-entry/crash-retry verification on a real Linux host and full MySQL/MariaDB attest → complete execution remain explicit acceptance work. This commit does not claim either was completed.
Monitored dependency R1-29 V19 is still free on main; #12894 and #12946 remain open. The hourly monitor will sync and renumber this migration if another PR claims the version first.

Local verification for this exact commit: npm run build, npm run typecheck, npm run bundle; runtime-broker 464 tests with 1 existing skip and Checkstyle clean; managed-agent-server 190 tests and Checkstyle clean. Remote CI is running on this SHA. The original Hosted turn remains blocked and no Shell execution is replayed by these fixes.

@wenshao

wenshao commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Maintainer physical verification report (Linux aarch64, real worker + MySQL 8.4)

I built and verified this PR on a physical Linux machine (CIX P1 CD8160 aarch64, kernel 6.6.89, Temurin JDK 21.0.12, Node 24 bundle built from the PR head, MySQL 8.4.11 in Docker) — the "physical Linux operator workflow" the PR description lists as pending. All results below are against the current head 84ac51816f (earlier heads d73a762e6a and 5e3484b285 were verified the same way). The operator-recovery feature itself works end-to-end and matches the design, but the PR deterministically breaks the Runtime Broker fault gates — the same failure seen in CI — and I root-caused it to an H2 2.3.232 storage bug triggered by the new DDL in runtime-broker/schema.sql. This must be fixed before merge.

1. The fault-gate regression (blocks merge)

CI job Hosted process fault gates / MySQL 8.4 / Java 21 fails on this PR; it fails identically on my physical box:

Run Result
PR head, -Pfault-gates (real broker + real worker processes) LocalRebootFaultGateTest 2/2 fail and DurableLocalRuntimeFaultGateTest 8/10 fail, all 409 runtime_admission_closed "Runtime generation no longer accepts new operations" at the first acquire — deterministic across repeats, matching the 10 CI failures
Pre-PR baseline 81582ca19d (what this PR merges), same machine, full suite 41 tests, 1 failure — a timing flake in adoptedWorkerCanCancelItsOriginalActiveCall (cancellation must reach the original active worker), a different assertion, not the systematic admission failure

fault gates

Root cause. I bisected the 4-file runtime-broker diff against a single failing test:

Variant Result
PR pristine FAIL 2/2 (409 admission_closed)
PR with schema.sql reverted to baseline PASS 2/2
PR with the new table kept but its UNIQUE (binding_id, runtime_generation) removed PASS 2/2
PR with h2.version bumped 2.3.232 → 2.4.240 (test scope only) PASS 2/2 — and the full 464-test broker suite also stays green on 2.4.240

Instrumenting JdbcRuntimeBindingRepository.compareAndSet shows the durable-provisioning READY update reports 1 row modified and commits, yet a fresh connection immediately re-reads the old PROVISIONING row — the commit is silently lost inside the H2 rig. The rig's H2 shutdown log shows an MVStore AssertionError in compactMoveChunks, and the trigger is layout-sensitive (renaming the table, or adding an unrelated table, flips the failure on and off). So adding the managed_workspace_operator_recovery DDL to runtime-broker/schema.sql trips an H2 2.3.232 MVStore storage bug in the fault-gate rig; admission then sees a PROVISIONING binding and closes.

h2 root cause

Blast radius: test infrastructure only. The durable provisioning logic is unchanged by this PR; on real MySQL 8.4 the full integration path passes (below), the managed-agent-server H2 suite passes (190/190, locally and in CI), and the MariaDB CI job is green. I suggest bumping h2.version in packages/sdk-java/runtime-broker/pom.xml to 2.4.240 (verified locally, unit suite + fault gates) or restructuring the DDL; please also keep an eye on the Spring-managed H2 2.3.232 used by managed-agent-server tests, since the corruption is layout-sensitive.

2. Physical operator-recovery run (the pending validation)

I drove the whole runbook on real infrastructure: real MySQL 8.4 with Flyway migrations through V19, a real durable local-process worker (node dist/cli.js managed-runtime-worker), a real detached escaped Shell writer that keeps appending to a Workspace marker after the Shell leader exits (same shape as LocalRebootFaultGateTest), and the shipped qwen-managed-agent-server-0.1.0-alpha-operator-recovery.jar for every operator step. One link is synthetic by necessity: the saved v3 partial/producer_lost record is written through the production repository settlement path — exactly how WorkspaceRecoveryContract.verifyOperatorPrepare creates it on the MySQL channel — because driving a full v3 turn requires the harness/model pipeline. The escape, the Workspace pin, the fence, the maintenance command, and the reuse are all physically real.

All 34 steps passed on the final head, including every negative case (the statement is written after prepare, so the run also exercises the evidence-window rule added in 5e3484b285):

  • Escaped writer confirmed alive and writing after the Shell leader exits; a second Session on the same Workspace is refused (Workspace storage is held by another tool turn).
  • inspect reports captureReason=producer_lost, the exact holder and the blocked call; prepare with a wrong holder is rejected; prepare is idempotent for the same account+reason and rejects a different one.
  • The live binding is fenced as OPERATOR_RECOVERY: ordinary release, new admission and the background recovery scan are all blocked.
  • complete with a valid private statement is refused while the registered worker is still running — the exit verification is real.
  • After the worker and the escaped writer are stopped, complete prints completed; a retry with the same file is safe; an altered statement is rejected even after completion.
  • Final state: original generation RELEASED with stop evidence operator-attested:<recoveryId>, Workspace lease cleared for the exact original holder, immutable audit row with attestation digest, the original Shell call still settled-partial and never replayed, and a new Session acquires, runs Shell in, and releases the reclaimed Workspace.

physical verification

audit trail

3. Suite results on this machine (PR head 84ac51816f)

Suite Result
runtime-broker unit tests (JDK 21) 464/464 pass
managed-agent-server H2 unit tests 190/190 pass
managed-agent-server hosted MySQL 8.4 failsafe (-Phosted-harness-mysql, real broker + worker + SQL) 10/10 pass (incl. HostedWorkspaceToolTurnIT and HostedWorkspaceRecoveryWorkerIT)
ManagedAgentMySqlIT on real MySQL 8.4 (incl. WorkspaceRecoveryContract.verify + verifyOperatorPrepare) 15/15 pass
runtime-broker fault gates (-Pfault-gates) 10 fail — see section 1
Physical operator runbook (section 2) 34/34 pass
npm run build && npm run bundle (Node 24) pass

Verdict

The design is sound and the implementation behaves exactly as specified on physical Linux — the recovery flow, fence semantics, evidence validation, audit trail and no-replay guarantees all verified, including the refusal paths. I am comfortable with the feature. Not mergeable as-is: the PR breaks the durable local-process fault gates on every run (CI red) via the H2 2.3.232 issue above; fixing that (e.g., the test-scope H2 bump) should make the fault-gate job green without touching production code.

中文版本(维护者物理验证报告)

维护者物理验证报告(Linux aarch64,真实 worker + MySQL 8.4)

我在物理 Linux 机器(CIX P1 CD8160 aarch64,内核 6.6.89,Temurin JDK 21.0.12,PR head 构建的 Node 24 bundle,Docker 中 MySQL 8.4.11)上完成构建与验证,补齐了 PR 描述中"尚待物理运维流程验收"的部分。以下结果均基于当前 head 84ac51816f(之前的 head d73a762e6a、5e3484b285 也做过同样验证)。运维恢复功能本身端到端可用、与设计一致;但 PR 确定性地破坏了 Runtime Broker 故障门禁(与 CI 失败一致),根因是 runtime-broker/schema.sql 新增 DDL 触发了 H2 2.3.232 存储 bug。合并前必须修复。

1. 故障门禁回归(阻塞合并)

CI 任务 Hosted process fault gates / MySQL 8.4 / Java 21 失败,我在物理机上复现完全一致:

运行 结果
PR head,-Pfault-gates(真实 broker + 真实 worker 进程) LocalRebootFaultGateTest 2/2 失败、DurableLocalRuntimeFaultGateTest 8/10 失败,全部在首次 acquire 时报 409 runtime_admission_closed——多次重跑结果确定,与 CI 的 10 个失败一致
PR 合并的基线 81582ca19d,同机全量 41 个测试仅 1 个时序 flake(adoptedWorkerCanCancelItsOriginalActiveCall 的取消断言,性质不同)

根因:对 runtime-broker 四文件 diff 做二分:

变体 结果
PR 原样 失败 2/2
仅回退 schema.sql 到基线 通过 2/2
保留新表但去掉 UNIQUE (binding_id, runtime_generation) 通过 2/2
仅将测试用 h2.version 2.3.232 → 2.4.240 通过 2/2——且 464 个 broker 单元测试在 2.4.240 下同样全绿

在 JdbcRuntimeBindingRepository.compareAndSet 内插桩可见:durable provisioning 的 READY 更新报告影响 1 行并提交,但新连接立即重读到的仍是旧的 PROVISIONING 行——提交在 H2 rig 内被静默丢弃。rig 的 H2 关闭日志出现 MVStore AssertionError(compactMoveChunks),且触发对 schema 布局敏感(重命名表或新增无关表都会改变结果)。因此是 runtime-broker/schema.sql 新增的 managed_workspace_operator_recovery DDL 触发了 H2 2.3.232 MVStore 存储 bug,admission 读到 PROVISIONING 于是关闭。

影响面:仅测试基础设施。PR 未改动 durable provisioning 逻辑;真实 MySQL 8.4 全链路通过(见下),managed-agent-server H2 套件 190/190 通过(本地与 CI 一致),MariaDB CI 任务绿色。建议将 packages/sdk-java/runtime-broker/pom.xml 的 h2.version 升到 2.4.240(本地已验证单元套件与故障门禁),或调整 DDL 写法;并留意 managed-agent-server 测试使用的 Spring 托管 H2 2.3.232,因为该 corruption 对布局敏感。

2. 物理运维恢复验证(补齐 PR 待验收项)

我在真实基础设施上驱动了完整 runbook:真实 MySQL 8.4(Flyway 迁移至 V19)、真实 durable local-process worker(node dist/cli.js managed-runtime-worker)、真实的逃逸 Shell 写入者(Shell 主进程退出后继续向 Workspace 标记文件追加,与 LocalRebootFaultGateTest 同形态),每个运维步骤都通过发布的 qwen-managed-agent-server-0.1.0-alpha-operator-recovery.jar 执行。唯一合成的一环:v3 partial/producer_lost 结算记录通过生产仓储结算路径写入(与 PR 自带的 MySQL 合约测试 WorkspaceRecoveryContract.verifyOperatorPrepare 完全同法),因为完整 v3 回合需要 harness/模型管线。逃逸、Workspace 占用、围栏、维护命令与复用均为物理真实。

34 个步骤在最终 head 上全部通过,含全部负面用例(证明在 prepare 之后写入,因此也覆盖了 5e3484b285 新增的证明时间窗校验):

  • 逃逸写入者在 Shell 主进程退出后存活并持续写入;同 Workspace 的第二个 Session 被拒(Workspace storage is held by another tool turn)。
  • inspect 正确返回 captureReason=producer_lost、精确 holder 与被阻调用;错误 holder 的 prepare 被拒;同账户同原因 prepare 幂等;不同原因被拒。
  • 存活 binding 被围栏为 OPERATOR_RECOVERY:普通释放、新准入、后台恢复扫描全部被阻断。
  • 已注册 worker 仍在运行时,complete 即使持有合法私有证明也被拒绝——退出核验真实生效。
  • 停止 worker 与逃逸写入者后,complete 返回 completed;同文件重试安全;改动证明即使完成后也被拒绝。
  • 终态:原代数 RELEASED,停止证据为 operator-attested:<recoveryId>;原 holder 的 Workspace 租约被精确清理;审计行含证明摘要且不可变;原 Shell 调用保持 settled-partial 且未重放;新 Session 成功获取、执行 Shell 并释放被回收的 Workspace。

3. 本机套件结果(PR head 84ac51816f)

套件 结果
runtime-broker 单元测试(JDK 21) 464/464 通过
managed-agent-server H2 单元测试 190/190 通过
managed-agent-server hosted MySQL 8.4 failsafe(-Phosted-harness-mysql,真实 broker + worker + SQL) 10/10 通过(含 HostedWorkspaceToolTurnIT、HostedWorkspaceRecoveryWorkerIT)
ManagedAgentMySqlIT(真实 MySQL 8.4,含 WorkspaceRecoveryContract.verify 与 verifyOperatorPrepare) 15/15 通过
runtime-broker 故障门禁(-Pfault-gates) 10 个失败——见第 1 节
物理运维 runbook(第 2 节) 34/34 通过
npm run build && npm run bundle(Node 24) 通过

结论

设计合理,实现在物理 Linux 上与规格完全一致——恢复流程、围栏语义、证明校验、审计追踪与禁重放保证(含各拒绝路径)均已验证。功能本身我认可。但当前不可合并:PR 每次都破坏 durable local-process 故障门禁(CI 红),即上述 H2 2.3.232 问题;修复它(例如升级测试作用域的 H2)即可让门禁转绿,无需改动生产代码。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

[codex] Thanks @wenshao for the physical Linux verification and the focused H2 A/B diagnosis. I agree that the deterministic fault-gate failures block this PR.

Feedback Action
10 first-acquire runtime_admission_closed failures in the Linux fault gates Upgraded the Runtime Broker's test-only H2 from 2.3.232 to 2.4.240 in 0f8ad6daf; no production dependency or schema changed.
Physical operator-recovery validation Updated the PR description to link your 34/34 runbook report and accurately distinguish the production-path seeded v3 outcome from a full model turn.

Local verification on the pushed commit: Broker 464 tests with no failures (one existing skip), zero Checkstyle violations, project build and typecheck passed. The new Linux fault-gate CI is the deciding regression check; a physical Broker restart after prepare remains unverified. No flaky rerun was used.

@wenshao

wenshao commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Maintainer re-verification: fault-gate fix confirmed, all green on 0f8ad6daf0

Follow-up to my earlier report: 0f8ad6daf0 applies exactly the suggested fix (test-scope H2 2.3.232 → 2.4.240) and resolves the blocker. I re-ran the full local verification on the new head (same physical Linux aarch64 box, Temurin JDK 21, MySQL 8.4.11, Node 24 bundle):

Suite Before (84ac51816f) Now (0f8ad6daf0)
Runtime Broker fault gates (-Pfault-gates, real broker + worker) 10 deterministic failures (409 runtime_admission_closed) 41/41 pass — matches the now-green CI job
runtime-broker unit tests 464/464 464/464 (also confirms the H2 bump is safe for the whole suite)
managed-agent-server H2 unit tests 190/190 190/190
Hosted MySQL 8.4 failsafe (-Phosted-harness-mysql) 10/10 10/10
ManagedAgentMySqlIT (incl. WorkspaceRecoveryContract.verify + verifyOperatorPrepare) 15/15 15/15
Physical operator runbook (real worker, real escaped writer, shipped jar) 34/34 34/34 — inspect/prepare/fence/refusals/complete/reuse all verified again

fault gates fixed

From my side this closes the last blocker: the feature is physically verified end-to-end and the test infrastructure is green. LGTM for merge once the remaining CI and review rounds settle.

中文版本(复核确认)

维护者复核:故障门禁修复确认,0f8ad6daf0 全绿

接我此前的报告:0f8ad6daf0 完全按建议修复(测试作用域 H2 2.3.232 → 2.4.240),阻塞项解除。我在新 head 上重跑了全部本地验证(同一台物理 Linux aarch64,Temurin JDK 21,MySQL 8.4.11,Node 24 bundle):

套件 修复前(84ac51816f) 修复后(0f8ad6daf0)
Runtime Broker 故障门禁(真实 broker + worker) 10 个确定性失败 41/41 通过——与转绿的 CI 一致
runtime-broker 单元测试 464/464 464/464(同时确认升级 H2 对整个套件无副作用)
managed-agent-server H2 单元测试 190/190 190/190
Hosted MySQL 8.4 failsafe 10/10 10/10
ManagedAgentMySqlIT(含恢复合约) 15/15 15/15
物理运维 runbook(真实 worker、真实逃逸写入者、发布的 jar) 34/34 34/34——inspect/prepare/围栏/各拒绝路径/complete/复用全部复验通过

我这边最后一个阻塞项已闭环:功能经物理端到端验证,测试基础设施全绿。待其余 CI 与评审轮次收敛后,建议合并。

@wenshao

wenshao commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed with AI assistance.

Scope

Reviewed: WorkspaceOperatorRecoveryStore.java · WorkspaceRecoveryCommand.java · V19__workspace_operator_recovery.sql · RuntimeBindingRecord.java (new OPERATOR_RECOVERY state and transitions) · LocalProcessRuntimeProvisioner.java (attestOperatorStop) · WorkspaceRecoveryApplication.java · test files.

Not reviewed: design doc wording (6 files) · schema.sql for H2 (only the MySQL V19 migration) · the pom.xml double-repackage configuration.

Summary

This PR is well-designed and the fixes in 84ac51816 address wenshao's findings comprehensively. No new blocking issues found.

Independent verification

  • R1-1 fix verified: prepare() now takes lockPlacementDomain before the FOR UPDATE, matching the placement guard pattern used by sibling transitions.
  • R1-2 fix verified: Version check now includes canConvertToInt() before asInt(), preventing the 4294967297L truncation bypass.
  • R1-26 fix verified: readEvidence opens with NOFOLLOW_LINKS and reads through a single bounded handle, preventing symlink redirection.
  • Evidence validation chain is correct: path containment → symlink rejection → permission check (rw-------) → size cap → UTF-8 round-trip → schema version → recoveryId match → timestamp window. Each guard is testable.
  • Attestation immutability: attest() uses WHERE attestation_sha256 IS NULL with FOR UPDATE, making the write idempotent for matching digests and rejecting conflicts.
  • State machine transitions: OPERATOR_RECOVERY → LOST requires hasStoppedWriters(); OPERATOR_RECOVERY blocks same-storage placement via the updated unreclaimed predicate.
  • Retry safety: complete() uses COALESCE(completed_at, ?) ensuring idempotent completion; attestation hash is immutable after first write.

Deferred items (from wenshao, acknowledged by author)

  • R1-4: Full orchestration test (inspect→prepare→complete with crash/retry) deferred to physical operator acceptance.
  • R1-15: attest-to-complete on MySQL deferred to database E2E acceptance.
  • R1-29: V19 migration collision with #12894 acknowledged; author will renumber at merge if needed.

Observation

The fixed 16-iteration loop in complete() (for (int step = 0; step < 16; step++)) is a deliberate liveness bound. If recovery doesn't converge in 16 steps, the operator retries the command. The attestation and audit row persist, so retry is safe and idempotent. This is a reasonable design choice for a maintenance tool.

No approval: the deferred integration tests (R1-4, R1-15) and the V19 collision (R1-29) remain open. However, the code-level correctness, security guards, and state machine design are solid.

@chiga0 chiga0 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.

Approval

Verdict: Approve.

Rationale: 独立审查确认 wenshao 的所有发现已在 84ac518 中修复。关键验证:

  • Critical (R1-1): requireHeld() 守卫已加,lockPlacementDomain 持有期间完成所有读取
  • 物理运维验证 34/34 步通过、41/41 故障门禁通过
  • 464/464 Broker 测试通过
  • 跨语言契约一致性已验证

Deferred items(非阻断):

  • R1-4: 完整编排测试(main 入口)→ 物理验收已覆盖
  • R1-15: attest-to-complete MySQL E2E → prepare+scan 已在 MySQL 验证
  • R1-29: V19 迁移号冲突 → 合并时处理

Review metadata:

  • review_id: 3618668184 (initial COMMENT)
  • coverage: full (Deep)
  • tiers attempted: static / runtime / contract
  • gates: 2/2 passed
  • unreviewed: none
  • defects found: 0 (独立审查)
  • defects found by others: 21 (wenshao, 1 Critical + 20 Suggestion; 18 fixed, 3 deferred)

@doudouOUC
doudouOUC dismissed wenshao’s stale review September 30, 2026 03:33

Already have 2 approves,3ks.

@doudouOUC
doudouOUC added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit eead626 Sep 30, 2026
115 of 117 checks passed
@doudouOUC
doudouOUC deleted the codex/issue-12904-operator-recovery branch September 30, 2026 03:34

@wenshao wenshao 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.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: reverse audit - did not converge within the reverse-audit round cap of 5, build-and-test - the three CLI-bundle-dependent CI lanes (fault-gates, hosted-harness-mysql, daemon-e2e) were not run locally — the agent returned no evidence of its walk twice.

Not explored to full depth (tool budget reached): chunk 1: none — I finished the checks I planned within budget.; "agent reverse-audit (round 1)": build-level confirmation of the classified jar's contents (running mvn package is prohibited in this review flow and no prebuilt jar exists in the worktree or…; "agent reverse-audit (round 1)": empirical confirmation of both holes — running the prepare -without- lockPlacementDomain mutation against the H2 contract, and inflating the MySQL schema past…; "agent reverse-audit (round 4)": did not execute or compile the sdk-java module (H2/MySQL channels) — every "pinned by X" statement above comes from reading the test sources ( WorkspaceRecovery…; "agent reverse-audit (round 4)": did not assess the behavioural delta of H2 2.4.240 on the broker's SQL/DDL beyond confirming the artifact exists and that L4 already covers the unbumped second …, and 4 more.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceOperatorRecoveryStore.java:187 — [review] the attestation records no completing-operator identity, so the permanent audit row attributes …
  • docs/users/hosted-workspace-recovery.md:5 — [review] the new runbook is not registered in docs/users/_meta.ts, the curated navigation for that directory
  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRecoveryCommand.java:86 — [review] operator_id comes from the client-settable JVM property user.name, so the audit row can name an acco…
  • packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java:15 — [review] OPERATOR_RECOVERY is unclassified in the broker's binding dispatch, so a fenced Workspace answers 503 runtime_binding…
  • docs/users/hosted-workspace-recovery.md:7 — [review] nothing links to the new runbook and the module README never names the maintenance jar or its opt-in flag

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:reverse audit - did not converge within the reverse-audit round cap of 5、build-and-test - the three CLI-bundle-dependent CI lanes (fault-gates, hosted-harness-mysql, daemon-e2e) were not run locally——该 agent 连续两次未返回任何检查过程的证据。

未探索到全部深度(达到工具调用预算):chunk 1:none — I finished the checks I planned within budget.;"agent reverse-audit (round 1)":build-level confirmation of the classified jar's contents (running mvn package is prohibited in this review flow and no prebuilt jar exists in the worktree or…;"agent reverse-audit (round 1)":empirical confirmation of both holes — running the prepare -without- lockPlacementDomain mutation against the H2 contract, and inflating the MySQL schema past…;"agent reverse-audit (round 4)":did not execute or compile the sdk-java module (H2/MySQL channels) — every "pinned by X" statement above comes from reading the test sources ( WorkspaceRecovery…;"agent reverse-audit (round 4)":did not assess the behavioural delta of H2 2.4.240 on the broker's SQL/DDL beyond confirming the artifact exists and that L4 already covers the unbumped second …,另有 4 条。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

Comment on lines +339 to +340
|| (state == State.OPERATOR_RECOVERY || state == State.LOST
|| state == State.RECOVERY_BLOCKED || state == State.FAILED)

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.

[Suggestion] R2-1: The same-storage / canonical-cwd placement block added here covers RECOVERY_BLOCKED and unreclaimed FAILED bindings, but the operator recovery command cannot prepare either class from the state it is actually in: prepare() calls capture(...) unconditionally and capture refuses when the session has no settled producer_lost Shell execution, and canPrepare excludes FAILED outright. The background scan will not clear them either, because recoverLost requires stopped-writer evidence. A binding that reaches one of these states from an ordinary loss - not an incomplete capture - therefore pins its physical storage and its canonical directory with no supported exit, and the runbook's step 1 refuses before it can start.

Witness:

probe, three fixtures per arm
intact PR:  placement(same storage)=BLOCKED runtime_placement_recovery_required
            placement(same cwd)=BLOCKED
            inspect/prepare=IllegalStateException: Exact Hosted Shell operator recovery is unavailable.
            FAILED fixture: same storage and same cwd both BLOCKED
added disjunct reverted: placement(same storage)=ALLOWED state=PROVISIONING
            FAILED same cwd=ALLOWED
            operator refusal unchanged
contrast:   RECOVERY_BLOCKED WITH a producer_lost capture -> eligibleForPrepare=true, prepare=OK OPERATOR_RECOVERY

Fix the exit, not the block: let the operator path accept this class (relax capture for a binding that has loss evidence and no producer_lost execution, and admit FAILED-with-seed to canPrepare), or document the real exit for these two states in the design doc and the runbook.

blocked_execution_call_id is VARCHAR(512) NOT NULL (V19__workspace_operator_recovery.sql:12), so a fix that drops the capture must supply a value rather than leaving it null.

Please add the test that must fail without the fix: a contract case that builds a LOST/RECOVERY_BLOCKED managed binding with loss evidence, a null stop evidence and no producer_lost execution row, asserting prepare succeeds - remove the fix and it must go red.

中文说明

[Suggestion] 本次新增的「同 storage / 同 canonical cwd 阻断放置」覆盖了 RECOVERY_BLOCKED 与未回收的 FAILED binding,但运维恢复命令无法处理这两类状态的实际情况:prepare() 无条件调用 capture(...),而该 session 没有已结算的 producer_lost Shell 执行时 capture 会拒绝;canPrepare 更是直接排除 FAILED。后台扫描也不会清理它们,因为 recoverLost 要求已停止写入者证据。因此,由普通丢失(而非不完整捕获)进入这两种状态的 binding 会连带其物理存储与 canonical 目录被永久钉住且没有任何受支持的出口,runbook 第 1 步在开始前就会拒绝。

证据(实测):intact PR 下同 storage / 同 cwd 放置均被拒(runtime_placement_recovery_required),inspect/prepare 抛 Exact Hosted Shell operator recovery is unavailable.,FAILED fixture 同样被拒;撤掉新增分支后上述放置变为放行;对照臂(RECOVERY_BLOCKED 但存在 producer_lost 捕获)可正常 prepare。

建议修复出口而非阻断:让运维路径接纳这一类(对有丢失证据、无 producer_lost 执行的 binding 放宽 capture,并把带 seed 的 FAILED 纳入 canPrepare),或在设计文档与手册中写明这两种状态的真实出口。注意 blocked_execution_call_id 为 VARCHAR(512) NOT NULL(V19__workspace_operator_recovery.sql:12),去掉 capture 的修复必须提供取值而不能留空。请补上"去掉修复即失败"的测试。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

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.

[Suggestion] R2-1: Every findOrCreate assertion made while a fence is up expects a throw — the placement fence is pinned only in the blocking direction, so an over-blocking regression (a slipped parenthesis merging the state/storage narrowing) produces a tenant-wide runtime_placement_recovery_required outage that the entire suite passes.

Witness:

sweep of 152 findOrCreate matches: every call near a fenced binding is wrapped in
assertThatThrownBy(...RuntimeBrokerException) — zero successful findOrCreate in a
tenant holding an unreclaimed fenced binding

Fix: add one positive control in verifyOperatorPrepare — findOrCreate with a fresh workspace id, a different storage id, and a different canonical cwd must succeed while the binding sits in OPERATOR_RECOVERY (and once more at LOST). Deleting the narrowing must turn that assertion red. The control must differ from the fenced binding in all three matchers the code reads (storageId, canonicalCwd, workspaceId) within the same tenant, or it trips the pre-existing workspaceId rule.

中文说明

[Suggestion] R2-1:封禁期间的每一条 findOrCreate 断言都期望抛出异常——放置围栏只在"阻断"方向被固定,而"过宽阻断"方向的回归(括号滑动合并了 state/storage 收窄分支)会导致租户级 runtime_placement_recovery_required 准入中断,且整个测试套件依然全绿。

证据:枚举 152 处 findOrCreate 调用——封禁 binding 附近的每一次调用都包在 assertThatThrownBy(...RuntimeBrokerException) 里;在持有未回收封禁 binding 的租户中,没有任何一处成功的 findOrCreate 断言。

修复:在 verifyOperatorPrepare 中加一条正向对照——使用全新 workspace id、不同 storageId、不同 canonicalCwd 的 findOrCreate 必须在 binding 处于 OPERATOR_RECOVERY(以及 LOST)时成功;删除收窄逻辑时该断言必须变红。对照请求必须在同一租户内与被封禁 binding 在三个匹配维度(storageId、canonicalCwd、workspaceId)上全部不同,否则会命中既有的 workspaceId 规则。

— glm-5.3-flash via Qwen Code /review (v0.24.6)

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.

[Suggestion] R2-1: Every findOrCreate assertion made while a fence is up expects a throw — the placement fence is pinned only in the blocking direction, so an over-blocking regression (a slipped parenthesis merging the state/storage narrowing) produces a tenant-wide runtime_placement_recovery_required outage that the entire suite passes.

Witness:

sweep of 152 findOrCreate matches: every call near a fenced binding is wrapped in
assertThatThrownBy(...RuntimeBrokerException) — zero successful findOrCreate in a
tenant holding an unreclaimed fenced binding

Fix: add one positive control in verifyOperatorPrepare — findOrCreate with a fresh workspace id, a different storage id, and a different canonical cwd must succeed while the binding sits in OPERATOR_RECOVERY (and once more at LOST). Deleting the narrowing must turn that assertion red. The control must differ from the fenced binding in all three matchers the code reads (storageId, canonicalCwd, workspaceId) within the same tenant, or it trips the pre-existing workspaceId rule.

中文说明

[Suggestion] R2-1:封禁期间的每一条 findOrCreate 断言都期望抛出异常——放置围栏只在"阻断"方向被固定,而"过宽阻断"方向的回归(括号滑动合并了 state/storage 收窄分支)会导致租户级 runtime_placement_recovery_required 准入中断,且整个测试套件依然全绿。

证据:枚举 152 处 findOrCreate 调用——封禁 binding 附近的每一次调用都包在 assertThatThrownBy(...RuntimeBrokerException) 里;在持有未回收封禁 binding 的租户中,没有任何一处成功的 findOrCreate 断言。

修复:在 verifyOperatorPrepare 中加一条正向对照——使用全新 workspace id、不同 storageId、不同 canonicalCwd 的 findOrCreate 必须在 binding 处于 OPERATOR_RECOVERY(以及 LOST)时成功;删除收窄逻辑时该断言必须变红。对照请求必须在同一租户内与被封禁 binding 在三个匹配维度(storageId、canonicalCwd、workspaceId)上全部不同,否则会命中既有的 workspaceId 规则。

— glm-5.3-flash via Qwen Code /review (v0.24.6)

Comment on lines +77 to +78
jdbc.execute((ConnectionCallback<Void>) connection -> {
JdbcRuntimeBindingRepository.lockPlacementDomain(connection,

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.

[Suggestion] R2-2: prepare now takes the tenant placement guard as the first statement of its transaction, so that guard is held until commit - across holder(...) and the whole capture(...) scan. The scan has no SQL-side selectivity (every field that decides its result lives inside reference_json/result_json) and no LIMIT, so it fetches and Jackson-parses two JSON documents for every settled execution of the session, and every other placement in the tenant - findOrCreate, compareAndSet - waits behind it. Trigger: an operator runs prepare for a binding whose session has a long tool history while another Hosted session in the same tenant is provisioning. The unrelated session's placement is blocked for the whole scan, and past innodb_lock_wait_timeout it surfaces as an unrelated placement error.

Witness:

not run - the transaction scope is read from the source (transaction.execute opens at :76, the
lambda returns at :145, with holder/key comparison/capture/INSERT/UPDATE inside), but the property
the harm turns on - an InnoDB lock wait reaching innodb_lock_wait_timeout - needs a production
MySQL run with a realistic settled-execution count that an in-memory H2 harness cannot produce.

Keep the guard, shrink what runs inside it: hoist the capture selection out of the guarded transaction (compute the candidate the way inspect already does, unlocked) and inside the transaction re-verify only that the stored blocked_execution_call_id is still the newest matching settled row for the locked runtime_session_id, throwing blocked() if it cannot be confirmed. Independently, make the query bounded and selective.

Suggested change
jdbc.execute((ConnectionCallback<Void>) connection -> {
JdbcRuntimeBindingRepository.lockPlacementDomain(connection,
Capture blockedCapture = capture(bindingId, generation, current.runtimeSessionId());

The audit insert and the state transition must stay under the guard - "prepare ... takes the tenant placement guard and atomically stores one audit operation" (docs/design/2026-09-29-hosted-operator-recovery.md, Local maintenance protocol) - so hoist the scan, not the insert or update.

Please add the test that must fail without this change: extend the guard-contention block in WorkspaceRecoveryContract.verifyOperatorPrepare with a counting DataSource wrapper asserting that no qwen_tool_execution SELECT is issued while the guard is held.

中文说明

[Suggestion] prepare 现在把租户 placement 锁作为事务的第一条语句获取,因此该锁会一直持有到提交——期间覆盖 holder(...) 与整个 capture(...) 扫描。该扫描没有任何 SQL 侧选择性(决定结果的字段都在 reference_json/result_json 里)、也没有 LIMIT,因此会把该 session 的每条已结算执行都取回并在 Java 里对每行解析两份 JSON;同一租户内其他所有放置路径(findOrCreate、compareAndSet)都在它后面排队。触发条件:运维对一条工具历史很长的 binding 执行 prepare,同时同租户另一个 Hosted session 正在做放置。结果是那个无关 session 的放置被整段扫描阻塞,超过 innodb_lock_wait_timeout 后表现为一个不相干的放置错误。

证据:无法实跑——事务范围可直接从源码读出(:76 开启、:145 返回,其间是 holder/比对/capture/INSERT/UPDATE),但危害所依赖的性质(InnoDB 锁等待达到 innodb_lock_wait_timeout)需要真实 MySQL 与真实的已结算执行量,内存 H2 无法产出。

建议保留锁、缩小锁内工作量:把 capture 的选择提到受保护事务之外(像 inspect 那样先无锁计算候选),事务内只重新确认已存的 blocked_execution_call_id 仍是该 runtime_session_id 最新的匹配已结算行,无法确认就 blocked()。审计 INSERT 与状态迁移必须继续留在锁内(设计文档「Local maintenance protocol」),即搬走扫描而不是搬走 INSERT/UPDATE。请补上「去掉该改动即失败」的测试:在 WorkspaceRecoveryContract.verifyOperatorPrepare 的锁争用块中加一个计数 DataSource 包装,断言持锁期间不发出任何 qwen_tool_execution SELECT。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

Comment on lines +194 to +195
if (!Arrays.equals(evidence,
new String(evidence, StandardCharsets.UTF_8).getBytes(StandardCharsets.UTF_8))) {

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.

[Suggestion] R2-3: This UTF-8 guard rejects a UTF-16 file that carries a BOM but accepts BOM-less UTF-16, and the very next statement parses those bytes through Jackson's byte-level encoding auto-detection. For ASCII/CJK text every BOM-less UTF-16LE byte is itself a legal UTF-8 byte, so decoding and re-encoding is byte-identical, Arrays.equals(...) is true, and the guard never fires; Jackson then sniffs the bytes as UTF-16 and parses them. The statement is then stored by attest as new String(evidence, UTF_8) into the permanent attestation_json row - NUL-interleaved mojibake rather than the statement the runbook requires to be UTF-8.

Witness:

case=UTF-16LE no BOM
  bytes=428 roundTrip[EQUAL] jacksonReadTree(bytes)=PARSED parsedMethod=host inspection
  readEvidence: ACCEPTED (428 bytes)
  storedAttestationText = NUL-interleaved mojibake
case=UTF-16 with BOM (the case the test covers)
  roundTrip[DIFFERENT] -> readEvidence: REJECTED IllegalArgumentException: Operator evidence must be UTF-8.
fix arm: readTree(decodedText) -> JsonParseException (the strict decoder alone does NOT flip it)

The load-bearing half of the fix is parsing the decoded text, so no byte-level sniffing can occur:

Suggested change
if (!Arrays.equals(evidence,
new String(evidence, StandardCharsets.UTF_8).getBytes(StandardCharsets.UTF_8))) {
JsonNode value = mapper.readTree(new String(evidence, StandardCharsets.UTF_8));

A strict decoder alone does not close this - those bytes are valid UTF-8, so CodingErrorAction.REPORT still reports OK - only removing the byte-level parse does.

The evidence bytes are hashed and stored verbatim, so the fix must keep rejecting non-UTF-8 rather than transcoding it.

Please add the test that must fail without the fix: a BOM-less UTF_16LE case in WorkspaceRecoveryCommandTest asserting readEvidence throws.

中文说明

[Suggestion] 这段 UTF-8 校验会拒绝带 BOM 的 UTF-16,却接受不带 BOM 的 UTF-16,而紧接着的语句又通过 Jackson 的字节级编码自动探测去解析这些字节。对于 ASCII/中日韩文本,无 BOM 的 UTF-16LE 每个字节本身都是合法的 UTF-8 字节,因此解码再编码结果完全一致、Arrays.equals(...) 为 true、校验永不触发;Jackson 随即把它识别为 UTF-16 并解析成功,最终 attest 以 new String(evidence, UTF_8) 把这份声明写进永久的 attestation_json 列,得到的是 NUL 交错的乱码,而不是手册要求必须是 UTF-8 的那份声明。

证据(实测):无 BOM UTF-16LE 往返一致、readTree(bytes) 解析成功、readEvidence 放行(428 字节),落盘内容为 NUL 交错乱码;带 BOM 的 UTF-16(即测试覆盖的那一种)往返不一致并被拒绝。修复臂 readTree(decodedText) 抛 JsonParseException——注意仅加严格解码器不能修复,因为这些字节本身就合法。

修复中真正起作用的一半是改为解析文本,从而不存在字节级探测。由于证据字节会被原样哈希与存储,修复必须继续拒绝非 UTF-8,而不是做转码。请补上「去掉修复即失败」的测试:在 WorkspaceRecoveryCommandTest 中加一个无 BOM UTF_16LE 用例并断言 readEvidence 抛异常。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

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.

[Suggestion] R2-3: capture()'s two other refusal arms — the non-Shell detection and the producer_lost reason check — are asserted nowhere: every fixture feeding the recovery store is a Shell capture (via protocol-3/deferred or toolName) with captureStatus=partial and captureReason=producer_lost.

Witness:

sweep: 3 recovery-store capture fixtures, all shell + partial + producer_lost;
storage_failed exists only in the transport layer (HttpRuntimeTransport:522) and
never reaches this store

Failure scenario: loosening the shell detection fences a workspace whose only unfinished call was a non-Shell tool; accepting any capture reason fences storage_failed captures — both outside the "Exact Hosted Shell" recovery contract, both invisible to the suite.

Fix: add two variant executions — (a) a settled non-Shell reference with partial/producer_lost; (b) a Shell execution with captureReason: storage_failed — each asserting inspect throws IllegalStateException. Each variant must be a settled execution with a result map (otherwise it throws for a missing capture, which proves nothing), and the positive fixture's eligibleForPrepare must stay true.

中文说明

[Suggestion] R2-3:capture() 的另外两条拒绝分支——非 Shell 检测与 producer_lost 原因检查——没有任何断言覆盖:送入恢复存储的所有 fixture 都是 Shell 捕获(经 protocol-3/deferred 或 toolName),且 captureStatus=partial、captureReason=producer_lost。

证据:3 个恢复存储捕获 fixture 全部为 shell + partial + producer_lost;storage_failed 只出现在传输层(HttpRuntimeTransport:522),从未到达该存储。

失败场景:放宽 Shell 检测会把唯一未完成调用是非 Shell 工具的 workspace 纳入可封禁范围;接受任意 captureReason 会把 storage_failed 捕获也纳入——都越出了"Exact Hosted Shell"恢复契约,且套件全绿。

修复:增加两个变体执行——(a) 已结算的非 Shell 引用(partial/producer_lost);(b) Shell 执行但 captureReason: storage_failed——各自断言 inspect 抛出 IllegalStateException。每个变体必须是带 result map 的已结算执行(否则会因"缺少捕获"抛出,证明不了任何事),且正向 fixture 的 eligibleForPrepare 必须保持为 true。

— glm-5.3-flash via Qwen Code /review (v0.24.6)

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.

[Suggestion] R2-3: capture()'s two other refusal arms — the non-Shell detection and the producer_lost reason check — are asserted nowhere: every fixture feeding the recovery store is a Shell capture (via protocol-3/deferred or toolName) with captureStatus=partial and captureReason=producer_lost.

Witness:

sweep: 3 recovery-store capture fixtures, all shell + partial + producer_lost;
storage_failed exists only in the transport layer (HttpRuntimeTransport:522) and
never reaches this store

Failure scenario: loosening the shell detection fences a workspace whose only unfinished call was a non-Shell tool; accepting any capture reason fences storage_failed captures — both outside the "Exact Hosted Shell" recovery contract, both invisible to the suite.

Fix: add two variant executions — (a) a settled non-Shell reference with partial/producer_lost; (b) a Shell execution with captureReason: storage_failed — each asserting inspect throws IllegalStateException. Each variant must be a settled execution with a result map (otherwise it throws for a missing capture, which proves nothing), and the positive fixture's eligibleForPrepare must stay true.

中文说明

[Suggestion] R2-3:capture() 的另外两条拒绝分支——非 Shell 检测与 producer_lost 原因检查——没有任何断言覆盖:送入恢复存储的所有 fixture 都是 Shell 捕获(经 protocol-3/deferred 或 toolName),且 captureStatus=partial、captureReason=producer_lost。

证据:3 个恢复存储捕获 fixture 全部为 shell + partial + producer_lost;storage_failed 只出现在传输层(HttpRuntimeTransport:522),从未到达该存储。

失败场景:放宽 Shell 检测会把唯一未完成调用是非 Shell 工具的 workspace 纳入可封禁范围;接受任意 captureReason 会把 storage_failed 捕获也纳入——都越出了"Exact Hosted Shell"恢复契约,且套件全绿。

修复:增加两个变体执行——(a) 已结算的非 Shell 引用(partial/producer_lost);(b) Shell 执行但 captureReason: storage_failed——各自断言 inspect 抛出 IllegalStateException。每个变体必须是带 result map 的已结算执行(否则会因"缺少捕获"抛出,证明不了任何事),且正向 fixture 的 eligibleForPrepare 必须保持为 true。

— glm-5.3-flash via Qwen Code /review (v0.24.6)

Comment on lines +130 to +131
jdbc.update("DELETE FROM managed_workspace_operator_recovery WHERE recovery_id = ?", recoveryId);
assertThat(fixture.bindings.findRecoveryCandidates("local-process", null, 100))

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.

[Suggestion] R2-4: These three recovery-candidate assertions read a global, UUID-ordered LIMIT 100 sweep as if it were fixture-scoped. findRecoveryCandidates is the cross-tenant background sweep (it filters on provisioner_kind and binding_id, with no tenant predicate) and binding ids are random UUIDs, so it returns the 100 lexicographically smallest candidate ids in the whole database. ManagedAgentMySqlIT migrates one long-lived external schema and never cleans it, so once that schema accumulates more than ~100 candidates the anyMatch fails with "expected to find ... but none matched" - green by luck, red without a regression - while the DELETE-then-anyMatch pair silently stops pinning the audit-row exclusion.

Witness:

real repository, H2:  A) one candidate      -> targetRank=1   targetInFirstPage=true  (assertion would pass)
                      B) +150 smaller ids   -> total=151 rank=151 targetInFirstPage=false (assertion would fail)
                      C) same, audit row inserted -> targetInFirstPage=false (row presence makes no difference)
                      D) cursor walk       -> pages=2 reachedTarget=true (the suggested fix works)
CI reachability: the mysql-integration lane uses a fresh mariadb container per run, so this bites a
repeat-runner against a persistent local MariaDB/MySQL, not CI.

Assert on the fixture's own row (a scoped read), or page the sweep with the cursor until the binding is found or the sweep is exhausted.

Do not narrow the production query to the fixture's tenant: the same method is the maintenance sweep's paging source (RuntimeRecoveryCoordinator.scan).

Please add the test that pins it: pre-insert 100+ local-process candidate bindings for other tenants before this contract method runs - the current assertion must go red without the fix.

中文说明

[Suggestion] 这三处候选断言把一个全局的、按 UUID 排序的 LIMIT 100 分页当作只属于自己 fixture 的数据来读。findRecoveryCandidates 是跨租户的后台扫描(只按 provisioner_kind 与 binding_id 过滤,没有租户条件),而 binding id 是随机 UUID,所以它返回的是全库按字典序最小的 100 个候选。ManagedAgentMySqlIT 迁移的是一个长期存在且从不清理的外部 schema,一旦候选累积超过约 100 条,anyMatch 就会以「expected to find ... but none matched」失败——靠运气绿、没有回归也会红——同时 DELETE 后再 anyMatch 的那一对也悄悄失去了对审计行排除的固定作用。

证据(实测):真实仓储 + H2——A) 仅一个候选时目标排名 1、在首页、断言会通过;B) 加入 150 个更小 id 后总数 151、排名 151、不在首页、断言会失败;C) 同样条件下插入审计行结果不变,说明审计行存在与否对分页结果没有影响;D) 游标翻页两页即可命中目标,即建议的修法可行。CI 可达性:mysql 集成 lane 每次运行使用全新的 mariadb 容器,因此该问题影响的是对本机持久 MariaDB/MySQL 反复运行的人,而不是 CI。

建议改为对 fixture 自身行做有范围查询,或用游标翻页直到找到该 binding 或扫描结束;不要把生产查询收窄到 fixture 的租户,因为同一方法就是维护扫描的分页来源。请补上固定的测试:在该合约方法运行前预置 100 条以上其他租户的 local-process 候选 binding。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

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.

[Suggestion] R2-4: The only witness that prepare serializes behind the placement-domain lock is the 100 ms TimeoutException window — and that discriminator measures thread scheduling, not the lock. On a descheduled or SQL-latency-loaded CI runner, prepare exceeds 100 ms, the expected timeout fires anyway, and a lock-bypass regression ships green: every later assertion (idempotent re-prepare, fence state, placement throws) passes in both the blocked and bypassed worlds.

Witness:

quoted test body: the only in-window discriminator is
pending.get(100, TimeUnit.MILLISECONDS) — nothing else runs before connection.commit()

Fix: strengthen the in-window discrimination — while still holding the guard lock (before connection.commit()), assert the recovery-row count for this binding is still zero. A lock-bypassing prepare commits its INSERT inside the window and the count goes non-zero; a correctly blocking prepare inserts nothing. (Under READ COMMITTED this narrows rather than eliminates the race, but a state-delta probe is strictly stronger than the exception alone.)

中文说明

[Suggestion] R2-4:prepare 在放置域锁后串行化的唯一证据是 100 ms TimeoutException 窗口——而该判别器度量的是线程调度而非锁。在处于去调度或 SQL 延迟负载的 CI runner 上,prepare 超过 100 ms、预期超时照样触发,锁被绕过的回归因此全绿:后续所有断言(幂等重入 prepare、围栏状态、放置抛出)在被阻断与被绕过两种世界里都能通过。

证据:测试体原文——窗口内唯一的判别是 pending.get(100, TimeUnit.MILLISECONDS);connection.commit() 之前没有其他任何检查。

修复:加强窗口内的判别——在仍持有 guard 锁时(connection.commit() 之前),断言该 binding 的恢复行计数仍为 0。绕锁的 prepare 会在窗口内提交 INSERT、计数变为非零;正确阻塞的 prepare 不会插入任何行。(READ COMMITTED 下这只是收窄而非消除竞态,但状态差值探针严格强于仅凭异常。)

— glm-5.3-flash via Qwen Code /review (v0.24.6)

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.

[Suggestion] R2-4: The only witness that prepare serializes behind the placement-domain lock is the 100 ms TimeoutException window — and that discriminator measures thread scheduling, not the lock. On a descheduled or SQL-latency-loaded CI runner, prepare exceeds 100 ms, the expected timeout fires anyway, and a lock-bypass regression ships green: every later assertion (idempotent re-prepare, fence state, placement throws) passes in both the blocked and bypassed worlds.

Witness:

quoted test body: the only in-window discriminator is
pending.get(100, TimeUnit.MILLISECONDS) — nothing else runs before connection.commit()

Fix: strengthen the in-window discrimination — while still holding the guard lock (before connection.commit()), assert the recovery-row count for this binding is still zero. A lock-bypassing prepare commits its INSERT inside the window and the count goes non-zero; a correctly blocking prepare inserts nothing. (Under READ COMMITTED this narrows rather than eliminates the race, but a state-delta probe is strictly stronger than the exception alone.)

中文说明

[Suggestion] R2-4:prepare 在放置域锁后串行化的唯一证据是 100 ms TimeoutException 窗口——而该判别器度量的是线程调度而非锁。在处于去调度或 SQL 延迟负载的 CI runner 上,prepare 超过 100 ms、预期超时照样触发,锁被绕过的回归因此全绿:后续所有断言(幂等重入 prepare、围栏状态、放置抛出)在被阻断与被绕过两种世界里都能通过。

证据:测试体原文——窗口内唯一的判别是 pending.get(100, TimeUnit.MILLISECONDS);connection.commit() 之前没有其他任何检查。

修复:加强窗口内的判别——在仍持有 guard 锁时(connection.commit() 之前),断言该 binding 的恢复行计数仍为 0。绕锁的 prepare 会在窗口内提交 INSERT、计数变为非零;正确阻塞的 prepare 不会插入任何行。(READ COMMITTED 下这只是收窄而非消除竞态,但状态差值探针严格强于仅凭异常。)

— glm-5.3-flash via Qwen Code /review (v0.24.6)

<checkstyle-maven-plugin.version>3.6.0</checkstyle-maven-plugin.version>
<fastjson2.version>2.0.65</fastjson2.version>
<h2.version>2.3.232</h2.version>
<h2.version>2.4.240</h2.version>

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.

[Suggestion] R2-5: This H2 bump moves only one of the two modules that run the same SQL and the same duplicated DDL. runtime-broker now resolves 2.4.240 while managed-agent-server still takes Spring Boot's managed 2.3.232, so the broker's H2-JDBC suites and the server's WorkspaceRuntimeTest / WorkspaceRecoveryContract exercise the new NOT EXISTS clause, the widened state list and the new table on two different engines - and any engine-behaviour difference reds in one suite and passes in the other.

Witness:

mvn dependency:tree
runtime-broker        -> com.h2database:h2:jar:2.4.240:test
managed-agent-server  -> com.h2database:h2:jar:2.3.232:test
(the server declares h2 version-less under spring-boot-starter-parent 3.5.16)
The new query and table were exercised on the server lane's H2 2.3.232 and worked, so this is a
coverage-asymmetry finding rather than a demonstrated engine bug.

Either pin the same version in the server module (or a shared parent), or record in the PR which H2 2.3.232 behaviour the bump is for.

h2 is test scope in runtime-broker and version-less in the server, so the pin belongs on the server side and cannot affect production MySQL either way.

中文说明

[Suggestion] 这次 H2 升级只动了同时运行同一份 SQL 与同一份重复 DDL 的两个模块中的一个:runtime-broker 现在解析到 2.4.240,而 managed-agent-server 仍使用 Spring Boot 默认管理的 2.3.232。于是新增的 NOT EXISTS 子句、放宽的状态列表和新表,在 broker 的 H2-JDBC 套件与 server 的 WorkspaceRuntimeTest/WorkspaceRecoveryContract 上跑在两个不同的引擎上——任何引擎行为差异都会只让其中一个套件红或绿。

证据(实测依赖树):runtime-broker -> com.h2database:h2:jar:2.4.240:test;managed-agent-server -> com.h2database:h2:jar:2.3.232:test(server 未声明版本,继承自 spring-boot-starter-parent 3.5.16)。新查询与新表在 server lane 的 H2 2.3.232 上实测可用,因此这是覆盖不对称/一致性问题,而不是已证实的引擎缺陷。

建议在 server 模块(或共享 parent)中固定同一版本,或在 PR 中说明该升级针对的是 2.3.232 的哪个行为。h2 在 runtime-broker 中是 test scope、在 server 中无版本,因此固定应落在 server 侧,且无论哪种方式都不影响生产 MySQL。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

Files.writeString(evidence, valid.replace("\"version\":1", "\"version\":4294967297"));
assertThatThrownBy(() -> WorkspaceRecoveryCommand.readEvidence(directory, evidence,
recoveryId, preparedAt, mapper)).isInstanceOf(IllegalArgumentException.class);
Files.writeString(evidence, valid.replace("\"restartPrevention\":true", "\"restartPrevention\":false"));

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.

[Suggestion] R2-7: The evidence gate does not actually enforce the runbook's "exactly these fields" - it does not parse strictly. readEvidence validates with the shared default ObjectMapper and then checks value.size() != 6; ObjectNode.size() counts distinct field names and the default is last-wins with duplicate detection off, so a duplicate-key document collapses to 6 names and is accepted. Trailing content after the first document is likewise never inspected. The whole byte string is what the gate returns, and attest persists and hashes it verbatim, so the archived immutable operator statement can contain text the gate never validated.

Witness:

real readEvidence, module's Jackson:
default mapper:  duplicate-key (7 pairs / 6 names)  -> ACCEPTED (214 bytes archived verbatim)
                 trailing 2nd document               -> ACCEPTED (201 bytes archived verbatim)
                 a single distinct 7th field         -> rejected (Operator evidence is incomplete.)
Spring Boot's Jackson2ObjectMapperBuilder defaults behave identically.
strict mapper (STRICT_DUPLICATE_DETECTION + FAIL_ON_TRAILING_TOKENS):
                 duplicate-key -> JsonParseException: Duplicate field 'restartPrevention'
                 trailing      -> MismatchedInputException: Trailing token ... FAIL_ON_TRAILING_TOKENS

Parse with a strictly-configured mapper. The module already has the precedent:

Suggested change
Files.writeString(evidence, valid.replace("\"restartPrevention\":true", "\"restartPrevention\":false"));
JsonNode value = strictMapper.readTree(evidence);

ManagedExtensionRecordStore builds exactly this instance (STRICT_DUPLICATE_DETECTION + FAIL_ON_TRAILING_TOKENS), commented "no duplicate keys, no trailing content".

The mapper reaching readEvidence is the application-wide Spring ObjectMapper bean, so strictness must be added on a dedicated instance rather than by flipping the shared bean.

Please add the test that must fail without the fix: assert IllegalArgumentException for a duplicate restartPrevention pair and for a second appended document - both are accepted today.

中文说明

[Suggestion] 证据校验实际上并未落实手册所说的「恰好这些字段」——它没有严格解析。readEvidence 用共享的默认 ObjectMapper 校验后再检查 value.size() != 6,而 ObjectNode.size() 统计的是去重后的字段名数量,默认策略又是「后者覆盖」且不检测重复键,因此含重复键的文档折叠成 6 个名字后即被接受;首个文档之后的尾随内容同样从不被检查。校验返回的是整段字节,而 attest 会原样持久化并哈希,于是归档的那份不可变运维声明可能包含校验从未看过的内容。

证据(实测):默认 mapper 下,7 个键值对(6 个不同名)被接受并按 214 字节原样归档;尾随第二个文档被接受并按 201 字节归档;而单独的第七个不同字段被正确拒绝。Spring Boot 的 Jackson2ObjectMapperBuilder 默认行为一致。换成严格 mapper(STRICT_DUPLICATE_DETECTION + FAIL_ON_TRAILING_TOKENS)后,重复键抛 Duplicate field 'restartPrevention',尾随内容抛 MismatchedInputException: Trailing token。

建议改用严格配置的 mapper 解析,本模块已有现成先例(ManagedExtensionRecordStore 正是这样构造的,注释写明「不允许重复键、不允许尾随内容」)。注意传入 readEvidence 的 mapper 是全应用共享的 Spring ObjectMapper bean,因此严格性要加在专用实例上,而不是改动共享 bean。请补上「去掉修复即失败」的测试。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

Comment on lines +134 to +135
var blockedFixture = new Fixture(source, jdbc, store);
var blocked = blockedFixture.runtime("blocked").binding();

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.

[Suggestion] R2-8: The new state == State.FAILED arm of blocksPlacement's managed-context predicate has no witness. This placement-block section asserts refusals for OPERATOR_RECOVERY (both disjuncts) and RECOVERY_BLOCKED only; no test anywhere in the tree asserts a refused placement for a FAILED binding, and blocksPlacement has no direct test call site. Deleting that one disjunct keeps the whole suite green - including the MySQL IT that runs this contract - while a managed placement request for a different workspace of the same tenant that reuses the failed binding's storage id or canonical cwd is admitted again.

Witness:

real JdbcRuntimeBindingRepository + H2, managed binding forced to FAILED with a provision seed:
PR head:                       same-storage=REFUSED runtime_placement_recovery_required
                               same-cwd=REFUSED   control=ADMITTED
with `|| state == State.FAILED` deleted from :340:
                               same-storage=ADMITTED  same-cwd=ADMITTED  (probe assertion fails)
whole runtime-broker module with the arm removed:
  Tests run: 467, Failures: 0, Errors: 3, Skipped: 2
  (the 3 errors are JdbcRuntimeBrokerMySqlIT requiring mysql.url - identical on the intact tree,
   so nothing in the suite discriminates the arm)
grep: the managed-agent-server test tree constructs no FAILED binding at all.

Add a FAILED fixture next to blockedFixture and assert the same two refusals for a renamed workspace sharing its storage id and for one sharing its canonical cwd.

The fixture must keep a provision seed and the request a non-null storageId: blocksPlacement's unreclaimed term gates this arm on state == State.FAILED && provisionSeed != null, and isManagedContext() is exactly storageId != null, so the arm is unreachable without both.

Please add the test that must fail without the fix: the new assertThatThrownBy(...findOrCreate(...)) on that fixture must go red when the FAILED disjunct is removed.

中文说明

[Suggestion] blocksPlacement 受管上下文谓词中新增的 state == State.FAILED 分支没有任何见证。该放置阻断小节只对 OPERATOR_RECOVERY(两个分支)与 RECOVERY_BLOCKED 断言了拒绝;整棵树里没有任何测试对 FAILED binding 断言过放置被拒,blocksPlacement 也没有直接的测试调用点。删掉这一个分支后整套测试仍然全绿——包括会运行该合约的 MySQL 集成测试——而同一租户下复用该失败 binding 的 storage id 或 canonical cwd、但工作区已重命名的受管放置请求又会重新被放行。

证据(实测):真实 JdbcRuntimeBindingRepository + H2,把受管 binding 强制为带 provision seed 的 FAILED:PR head 下同 storage / 同 cwd 均被拒(runtime_placement_recovery_required)、对照组放行;从 :340 删掉该分支后两者都变为放行。把该分支删除后运行整个 runtime-broker 模块:Tests run: 467, Failures: 0, Errors: 3, Skipped: 2,那 3 个 error 是 JdbcRuntimeBrokerMySqlIT 缺少 mysql.url,在未改动的树上完全相同,即套件里没有任何东西能区分该分支。同时 grep 显示 managed-agent-server 的测试树根本没有构造 FAILED binding。

建议在 blockedFixture 旁增加一个 FAILED fixture,并对复用它 storage id 与 canonical cwd 的重命名工作区分别断言拒绝。fixture 必须保留 provision seed、请求必须带非空 storageId,否则该分支不可达。请补上「去掉修复即失败」的测试。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

Comment on lines +114 to +115
if (saved.getState() != RuntimeBindingRecord.State.RELEASED) {
throw new IllegalStateException("Completed recovery has no retired Runtime.");

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.

[Suggestion] R1-4: The whole inspect/prepare/complete orchestration, including the crash-and-retry resume branch, is still untested this round - no test enters main. WorkspaceRecoveryCommandTest calls only the package-private readEvidence, and WorkspaceRuntimeTest re-implements the call sequence against the store instead of invoking main, so a dispatch regression (wrong expected arg count, a branch that returns before compareAndSet/recoverBinding, a reordered branch, or the loop bound), a removed conjunct of the four-way opt-in gate, or a broken crash-retry resume branch compiles and keeps every suite green - and is only discovered by the operator running this offline tool during an incident.

Witness:

grep over packages/sdk-java/**/src/test/**: the only caller of WorkspaceRecoveryCommand is
WorkspaceRecoveryCommandTest's readEvidence cases; no test invokes main.
Independently corroborated by the qwen-triage stage-2 comment on this PR (S2) and by the PR body's
own disclosure that a physical Broker restart after prepare was not validated.

Extracting the dispatch into a package-private method over (String[], already-constructed beans) makes it testable without a Spring context: assert inspect/prepare/complete with the right arity, a wrong arg count rejected, and the LOST-with-stopped-writers resume branch.

中文说明

[Suggestion] R1-4:整条 inspect/prepare/complete 编排(含崩溃重试的续跑分支)本轮仍然没有测试——没有任何测试进入 main。WorkspaceRecoveryCommandTest 只调用包私有的 readEvidence,WorkspaceRuntimeTest 则对着 store 重写了一遍调用序列而不调用 main。因此分发层回归(参数个数写错、某个分支在 compareAndSet/recoverBinding 之前就 return、分支顺序错乱、循环上界、四段式开关少一个条件,或续跑分支被破坏)都能编译通过且所有套件保持全绿,只有运维在事故中真正运行这个离线工具时才会发现。

证据:对 packages/sdk-java/**/src/test/** 的 grep 显示 WorkspaceRecoveryCommand 的唯一调用方是 WorkspaceRecoveryCommandTest 的 readEvidence 用例;没有测试调用 main。该结论也得到本 PR 上 qwen-triage stage-2 评论(S2)与 PR 正文自身披露(prepare 之后的 Broker 物理重启未验证)的独立印证。

建议把分发逻辑抽成接收 (String[], 已构造好的 bean) 的包私有方法,从而无需 Spring 上下文即可测试:断言 inspect/prepare/complete 的正确参数个数、错误参数个数被拒,以及 LOST + 已停止写入者证据的续跑分支。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

Comment on lines +185 to +186
jdbc.update("UPDATE managed_workspace_operator_recovery"
+ " SET attestation_json = ?, attestation_sha256 = ?,"

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.

[Suggestion] R1-15: The immutable proof write and the completion marker still run only on H2, never on the MySQL/MariaDB channel. The MySQL integration test now calls verifyOperatorPrepare, but that contract method ends after the prepare / scan-exclusion / fence assertions and never calls attest(...) or complete(...); the only exercise of the proof write and the completion marker is the H2 WorkspaceRuntimeTest. A MySQL-specific failure in the attestation UPDATE or the COALESCE completion marker - type, collation, or DATETIME(6) handling - therefore ships unobserved.

Witness:

read of the MySQL channel at the reviewed head: ManagedAgentMySqlIT:961 calls
verifyOperatorPrepare only (plus verify), and verifyOperatorPrepare's body (lines 28-187)
contains no attest/complete call; grep for `recovery.attest(` / `recovery.complete(` on the
MySQL path returns nothing.

Extending the shared contract through attestation and completion makes both channels run the same assertions, since WorkspaceRecoveryTest (H2) and ManagedAgentMySqlIT (real MySQL) both drive it.

中文说明

[Suggestion] R1-15:不可变证明的写入与完成标记仍然只在 H2 上运行,从未在 MySQL/MariaDB 通道上执行。MySQL 集成测试现在会调用 verifyOperatorPrepare,但该方法在 prepare / 扫描排除 / 围栏断言之后即结束,从不调用 attest(...) 或 complete(...);证明写入与完成标记的唯一演练点是 H2 上的 WorkspaceRuntimeTest。因此,attestation 的 UPDATE 或 COALESCE 完成标记在 MySQL 上可能出现的特有失败(类型、排序规则或 DATETIME(6) 处理)都不会被观察到。

证据:在本轮 reviewed head 上读取 MySQL 通道——ManagedAgentMySqlIT:961 只调用了 verifyOperatorPrepare(以及 verify),而 verifyOperatorPrepare 的方法体(第 28–187 行)不含任何 attest/complete 调用。

建议把共享合约延伸到证明写入与完成阶段,这样 H2 与真实 MySQL 两条通道会执行相同的断言。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

@@ -0,0 +1,21 @@
CREATE TABLE managed_workspace_operator_recovery (

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.

[Suggestion] R1-29: V19 is still free on main, but open PR #12894 still claims V19 (and V20), and the collision fails at service startup rather than at merge. git sees no conflict because the filenames differ, and CI cannot see it because each PR's Flyway run only loads its own migrations - so whichever of the two lands second fails with Found more than one migration with version 19. main is at V18 and this PR's merge base carries no V19.

Witness:

gh pr view 12894 --repo QwenLM/qwen-code
  state: OPEN
  files: .../db/migration/V19__managed_tool_publication.sql, .../db/migration/V20__managed_tool_publication_objects.sql
git ls-tree origin/main -- .../db/migration/  -> no V19
Independently confirmed by the qwen-triage stage-2 comment on this PR (S1), which also names
#12946/#13037 colliding on V21.

The author's hourly monitor is watching merge order; flagging it so the renumber is not missed if this PR is the one that lands second.

中文说明

[Suggestion] R1-29:V19 目前在本仓库 main 上仍然空闲,但开放中的 PR #12894 同样占用 V19(以及 V20),而该冲突会在服务启动时而不是在合并时失败。git 因为文件名不同而看不到冲突,CI 也看不到,因为每个 PR 的 Flyway 只加载自己的迁移——因此两者中后合入的一方会以 Found more than one migration with version 19 失败。main 目前停在 V18,本 PR 的合并基线上没有 V19。

证据(实测):gh pr view 12894 显示状态 OPEN 且文件包含 V19__managed_tool_publication.sql 与 V20__managed_tool_publication_objects.sql;git ls-tree origin/main 的迁移目录中没有 V19。本 PR 上的 qwen-triage stage-2 评论(S1)独立确认了同一问题,并指出 #12946/#13037 在 V21 上互撞。

作者的每小时监控已在关注合并顺序;此处仅作提示,以免本 PR 恰好是后合入的一方时漏掉改号。

— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)

@wenshao wenshao 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.

Partially reviewed — gaps disclosed.

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

  • R1-4 main orchestration untested — already re-posted at WorkspaceRecoveryCommand.java:115 (comment 4140743205)
  • R1-15 attest/complete untested on the MySQL channel — already re-posted at WorkspaceOperatorRecoveryStore.java:186 (comment 4140743210)
  • R1-29 V19 migration collision with open #12894 — already re-posted at V19__workspace_operator_recovery.sql:1 (comment 4140743216)
  • R1-16 (fix-induced) FAILED placement arm untested — already reported at RuntimeBindingRecord.java:340 (comment 4140743154) and WorkspaceRecoveryContract.java:135 (comment 4140743199)
  • R2-2 H2 version split across the shared contract suite — already reported at runtime-broker/pom.xml:16 (comment 4140743181)
  • R2-5 placement lock held across prepare's capture scan — already reported at WorkspaceOperatorRecoveryStore.java:78 (comment 4140743160)

Not reviewed: reverse audit — stopped at the plan's 5-round cap (round 5 still reported findings in chunks 4, 6, and 8; the cap forecloses a sixth round).

Not explored to full depth (tool budget reached): "agent 1b": none — all planned checks completed (~18 of ~69 tool calls used).; "agent reverse-audit (round 4)": none — no check was cut short by the ceiling; the only unverifiable step (local mvn package ) is impossible on this machine (no JVM) and is disclosed inside th…; chunk 6: none — all checks completed within budget (~14 tool calls of 33)..

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/maintenance/WorkspaceRecoveryApplication.java:9 — [review] Maintenance context's negative safety properties (no listener, no Flyway, no scan) asserted nowhere
  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceOperatorRecoveryStore.java:314 — [review] capture()'s completed-capture refusal arm unpinned
  • packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java:355 — [review] OPERATOR_RECOVERY fence's illegal escapes to RELEASED/FAILED/DRAINING/PROVISIONING unpinned
  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceOperatorRecoveryStore.java:301 — [review] capture() scans and JSON-parses the entire settled execution history (no LIMIT, paid twice per …
  • packages/sdk-java/runtime-broker/src/main/resources/com/alibaba/qwen/code/runtimebroker/schema.sql:75 — [review] blocked_execution_call_id is a write-only column — no read site, test, or doc

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R2-6: [certifies-falsely] [new-surface] The trusted-reboot reclaim path (warm/acquire → ensureBinding → reclaimLostBinding → recoverLost → observeDurable) never reads managed_workspace_operator_recovery — the audit fence exists only in findRecoveryCandidates — so with trusted-local-reboot-recovery=true, a reboot between prepare and complete releases the fenced binding and provisions a replacement without the operator's statement; the operator's later complete refuses on the evidence-source mismatch. Probe on the PR head: managed LOST binding + pending audit row + rebooted registration + service.warm() → final=RELEASED, stopEvidence=trusted-host-reboot, pendingAudit=1, replacement binding issued (expected LOST but was RELEASED). Flip check: with the fence extended to recoverLost, the binding stays LOST. direction: certifies-falsely; baseline: new-surface. Fix: extend the audit check to recoverLost — refuse (stay LOST) while a pending row exists and the stop-evidence source is not operator-attested:<recovery_id>; keep trusted-reboot release working for bindings with no pending audit row. Constraint: preserve the sanctioned W0e-3 release for no-audit bindings (DurableRuntimeRecoveryTest:1194-1203) and requireSafeReplacement's transition rules. Witness: probe, source tag [probe]. Fix witness: Extend WorkspaceRecoveryContract: after prepare on the LOST binding, claim as a simulated broker, compareAndSet trusted-reboot-style evidence, recoverLost — assert the binding remains LOST while the audit row is pending; red today (the release succeeds). Fix constraint: Trusted-reboot release must keep working for bindings with NO pending audit row (DurableRuntimeRecoveryTest:1194-1203); scope the refusal to pending audits only.

中文说明

仅完成部分审查,审查缺口已披露。

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

未审查(原文为英文):reverse audit — stopped at the plan's 5-round cap (round 5 still reported findings in chunks 4, 6, and 8; the cap forecloses a sixth round).

未探索到全部深度(达到工具调用预算):"agent 1b":none — all planned checks completed (~18 of ~69 tool calls used).;"agent reverse-audit (round 4)":none — no check was cut short by the ceiling; the only unverifiable step (local mvn package ) is impossible on this machine (no JVM) and is disclosed inside th…;chunk 6:none — all checks completed within budget (~14 tool calls of 33).。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R2-6: [certifies-falsely] [new-surface] The trusted-reboot reclaim path (warm/acquire → ensureBinding → reclaimLostBinding → recoverLost → observeDurable) never reads managed_workspace_operator_recovery — the audit fence exists only in findRecoveryCandidates — so with trusted-local-reboot-recovery=true, a reboot between prepare and complete releases the fenced binding and provisions a replacement without the operator's statement; the operator's later complete refuses on the evidence-source mismatch. Probe on the PR head: managed LOST binding + pending audit row + rebooted registration + service.warm() → final=RELEASED, stopEvidence=trusted-host-reboot, pendingAudit=1, replacement binding issued (expected LOST but was RELEASED). Flip check: with the fence extended to recoverLost, the binding stays LOST. direction: certifies-falsely; baseline: new-surface. Fix: extend the audit check to recoverLost — refuse (stay LOST) while a pending row exists and the stop-evidence source is not operator-attested:<recovery_id>; keep trusted-reboot release working for bindings with no pending audit row. Constraint: preserve the sanctioned W0e-3 release for no-audit bindings (DurableRuntimeRecoveryTest:1194-1203) and requireSafeReplacement's transition rules. Witness: probe, source tag [probe]. Fix witness: Extend WorkspaceRecoveryContract: after prepare on the LOST binding, claim as a simulated broker, compareAndSet trusted-reboot-style evidence, recoverLost — assert the binding remains LOST while the audit row is pending; red today (the release succeeds). Fix constraint: Trusted-reboot release must keep working for bindings with NO pending audit row (DurableRuntimeRecoveryTest:1194-1203); scope the refusal to pending audits only.

— glm-5.3-flash via Qwen Code /review (v0.24.6)

@wenshao wenshao 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.

Partially reviewed — gaps disclosed.

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

  • R1-4 main orchestration untested — already re-posted at WorkspaceRecoveryCommand.java:115 (comment 4140743205)
  • R1-15 attest/complete untested on the MySQL channel — already re-posted at WorkspaceOperatorRecoveryStore.java:186 (comment 4140743210)
  • R1-29 V19 migration collision with open #12894 — already re-posted at V19__workspace_operator_recovery.sql:1 (comment 4140743216)
  • R1-16 (fix-induced) FAILED placement arm untested — already reported at RuntimeBindingRecord.java:340 (comment 4140743154) and WorkspaceRecoveryContract.java:135 (comment 4140743199)
  • R2-2 H2 version split across the shared contract suite — already reported at runtime-broker/pom.xml:16 (comment 4140743181)
  • R2-5 placement lock held across prepare's capture scan — already reported at WorkspaceOperatorRecoveryStore.java:78 (comment 4140743160)

Not reviewed: reverse audit — stopped at the plan's 5-round cap (round 5 still reported findings in chunks 4, 6, and 8; the cap forecloses a sixth round).

Not explored to full depth (tool budget reached): "agent 1b": none — all planned checks completed (~18 of ~69 tool calls used).; "agent reverse-audit (round 4)": none — no check was cut short by the ceiling; the only unverifiable step (local mvn package ) is impossible on this machine (no JVM) and is disclosed inside th…; chunk 6: none — all checks completed within budget (~14 tool calls of 33)..

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/maintenance/WorkspaceRecoveryApplication.java:9 — [review] Maintenance context's negative safety properties (no listener, no Flyway, no scan) asserted nowhere
  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceOperatorRecoveryStore.java:314 — [review] capture()'s completed-capture refusal arm unpinned
  • packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java:355 — [review] OPERATOR_RECOVERY fence's illegal escapes to RELEASED/FAILED/DRAINING/PROVISIONING unpinned
  • packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceOperatorRecoveryStore.java:301 — [review] capture() scans and JSON-parses the entire settled execution history (no LIMIT, paid twice per …
  • packages/sdk-java/runtime-broker/src/main/resources/com/alibaba/qwen/code/runtimebroker/schema.sql:75 — [review] blocked_execution_call_id is a write-only column — no read site, test, or doc

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R2-6: [certifies-falsely] [new-surface] The trusted-reboot reclaim path (warm/acquire → ensureBinding → reclaimLostBinding → recoverLost → observeDurable) never reads managed_workspace_operator_recovery — the audit fence exists only in findRecoveryCandidates — so with trusted-local-reboot-recovery=true, a reboot between prepare and complete releases the fenced binding and provisions a replacement without the operator's statement; the operator's later complete refuses on the evidence-source mismatch. Probe on the PR head: managed LOST binding + pending audit row + rebooted registration + service.warm() → final=RELEASED, stopEvidence=trusted-host-reboot, pendingAudit=1, replacement binding issued (expected LOST but was RELEASED). Flip check: with the fence extended to recoverLost, the binding stays LOST. direction: certifies-falsely; baseline: new-surface. Fix: extend the audit check to recoverLost — refuse (stay LOST) while a pending row exists and the stop-evidence source is not operator-attested:<recovery_id>; keep trusted-reboot release working for bindings with no pending audit row. Constraint: preserve the sanctioned W0e-3 release for no-audit bindings (DurableRuntimeRecoveryTest:1194-1203) and requireSafeReplacement's transition rules. Witness: probe, source tag [probe]. Fix witness: Extend WorkspaceRecoveryContract: after prepare on the LOST binding, claim as a simulated broker, compareAndSet trusted-reboot-style evidence, recoverLost — assert the binding remains LOST while the audit row is pending; red today (the release succeeds). Fix constraint: Trusted-reboot release must keep working for bindings with NO pending audit row (DurableRuntimeRecoveryTest:1194-1203); scope the refusal to pending audits only.

中文说明

仅完成部分审查,审查缺口已披露。

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

未审查(原文为英文):reverse audit — stopped at the plan's 5-round cap (round 5 still reported findings in chunks 4, 6, and 8; the cap forecloses a sixth round).

未探索到全部深度(达到工具调用预算):"agent 1b":none — all planned checks completed (~18 of ~69 tool calls used).;"agent reverse-audit (round 4)":none — no check was cut short by the ceiling; the only unverifiable step (local mvn package ) is impossible on this machine (no JVM) and is disclosed inside th…;chunk 6:none — all checks completed within budget (~14 tool calls of 33).。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R2-6: [certifies-falsely] [new-surface] The trusted-reboot reclaim path (warm/acquire → ensureBinding → reclaimLostBinding → recoverLost → observeDurable) never reads managed_workspace_operator_recovery — the audit fence exists only in findRecoveryCandidates — so with trusted-local-reboot-recovery=true, a reboot between prepare and complete releases the fenced binding and provisions a replacement without the operator's statement; the operator's later complete refuses on the evidence-source mismatch. Probe on the PR head: managed LOST binding + pending audit row + rebooted registration + service.warm() → final=RELEASED, stopEvidence=trusted-host-reboot, pendingAudit=1, replacement binding issued (expected LOST but was RELEASED). Flip check: with the fence extended to recoverLost, the binding stays LOST. direction: certifies-falsely; baseline: new-surface. Fix: extend the audit check to recoverLost — refuse (stay LOST) while a pending row exists and the stop-evidence source is not operator-attested:<recovery_id>; keep trusted-reboot release working for bindings with no pending audit row. Constraint: preserve the sanctioned W0e-3 release for no-audit bindings (DurableRuntimeRecoveryTest:1194-1203) and requireSafeReplacement's transition rules. Witness: probe, source tag [probe]. Fix witness: Extend WorkspaceRecoveryContract: after prepare on the LOST binding, claim as a simulated broker, compareAndSet trusted-reboot-style evidence, recoverLost — assert the binding remains LOST while the audit row is pending; red today (the release succeeds). Fix constraint: Trusted-reboot release must keep working for bindings with NO pending audit row (DurableRuntimeRecoveryTest:1194-1203); scope the refusal to pending audits only.

— glm-5.3-flash via Qwen Code /review (v0.24.6)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants