Repository navigation
feat(cli): Mount the v2 tool operations on the Managed Runtime worker - #12671
Conversation
Extend the owned managed-runtime route manifest with the three tool operations and add their shared schema and conformance fixtures. The wire shape fixes the evidence rules the recovery design demands: every call is keyed by the original reference (sessionId, promptId, callId, argsDigest) rather than any Broker-side identifier; status is read-only and answers unknown with 200 instead of 404 or 500, because a missing record is never evidence that a call did not run; a settled state is the only one that may carry a result; and status additionally returns the Runtime's own lastSequence cursor. Execute accepts up to 256 KiB of request input, and every operation answers at most 1 MiB. No handler is mounted yet: per the manifest rule the routes join in the same line of slices as the execute extraction that implements them. The TypeScript gate test proves the owned-route gate admits exactly these paths, and the Java consumer pins the route contract, every outcome classification, and the closed field sets, so the worker and transport slices that follow share one reviewed target. Related to QwenLM#12380.
Co-authored-by: Qwen-Coder <[email protected]>
The attestation worker now serves execute, status, and cancel beside attest. Its executor admits exactly the first-slice ordinary tools — read_file, write_file, edit, and foreground run_shell_command — over a real Config rooted at the attested workspace cwd, with checkpointing disabled and no further approval gate, because admission happened on the Harness side. An in-memory journal answers status and cancel by the original reference; it is process-local by construction, since the worker process is the Runtime generation and a restart is a new generation rather than a continuation. execute is idempotent by reference.callId: the same identity joins the in-flight invocation or returns its settled result, and the same callId with a different digest or payload is a 409 identity conflict. status is read-only and answers unknown with 200 for a reference the Runtime never saw. cancel settles a prepared invocation without touching the tool, aborts an executing one, and a cancel the Runtime honored settles as cancelled whether the tool surfaces the abort as an error or as an early result. The worker's HTTP requestTimeout is disabled because an execute call holds its connection until settlement. Related to QwenLM#12380.
Real-environment verification — PR #12671 @
|
| Tree | PR's 3 suites | Real worker: execute / status / cancel |
main's 13 negative shared fixtures (real process) |
|---|---|---|---|
| PR head (as pushed) | 81/81 ✅ | — | — |
Merged into main (A) |
17 failed / 114 | 404, empty body (attest still 200) | 2/13 (only trailing-path and wrong-method, which expect 404) |
| A + gate fix (B) | 114/114 ✅ | 200 | 13/13 ✅ |
Candidate fix (verified in B; tsc --noEmit and eslint are clean):
--- a/packages/cli/src/serve/managed-runtime-attestation-contract.ts
+++ b/packages/cli/src/serve/managed-runtime-attestation-contract.ts
@@ export function isOwnedManagedRuntimeRoute(
- return method === ATTEST_ROUTE.method && url === ATTEST_ROUTE.path;
+ return OWNED_MANAGED_RUNTIME_ROUTES.some(
+ (route) => method === route.method && url === route.path,
+ );
--- a/packages/cli/src/serve/managed-runtime-attestation-contract.test.ts
+++ b/packages/cli/src/serve/managed-runtime-attestation-contract.test.ts
- it('rejects declared tool routes until their handlers land', async () => {
+ it('routes declared tool routes through the owned-route gate', async () => {
@@
- expect(response.status).toBe(404);
- expect(await response.text()).toBe('');
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ protocolVersion: 2,
+ state: 'unknown',
+ });The §3.1 sentence "Until the real tool handlers are mounted, ownedManagedRuntimeRouteGate admits only the exact attestation route…" also needs updating in both languages. Because this branch still contains its own copy of the #12630 commits, merging origin/main into it is the practical way to converge (not a rebase, since there are review threads). Take main's contract, schema, fixtures, Java, and doc files, then reapply the worker section of the doc.
What works (arm B, real process)
- Admitted tools:
read_filereads the workspace file.write_fileandeditchanged the file on disk toalpha\ngamma\n.editwith no match settles asexecutionStatus: "error"in 2 ms. It does not hang on the LLM-correction path, even with the synthetic model.- Foreground shell runs with cwd set to the attested workspace.
- Idempotency:
- Two concurrent
executecalls with the samecallIdboth return after about 1039 ms with an identical result. A third call after settlement returns in 2 ms. The side-effect counter file has 1 line, so the command ran once. - The same
callIdwith a different digest, or with a different input, returns 409managed_runtime_identity_conflict. - An unadmitted tool (
glob) returns 409.
- Two concurrent
- Status and cancel:
- Unseen references return 200
unknownon bothstatusandcancel. statusreturnsexecuting/lastSequence: 1for a running call. After settlement it returns the result withlastSequence(2, or 3 after a cancel).cancelon a running shell returnscancel_requested. The heldexecutethen returns at 818 ms withexecutionStatus: "cancelled", and the shell PID is gone. A repeated cancel returns the settled result.- Responses follow the merged schema:
executeandcancelcarry nolastSequence, and non-settled states carry noresult.
- Unseen references return 200
- Response size: Output stays well under the 1 MiB cap because the tools truncate themselves:
read_fileof a 3 MB text file → 25 KB- 1500 lines × 1900 chars → 25 KB
- 3 MB of shell stdout → 4.7 KB
- Shutdown: SIGTERM during a foreground
executeexits in 520 ms, and the shell child is killed.
Finding 2 — Should fix: is_background: true gets past the "foreground only" admission
The executor admits run_shell_command without checking is_background. When a call sets it:
executesettles immediately assuccesswithBackground shell started … pid: 58430.cancelthen returns that settled result, and the process keeps running (alive after cancel=true).- SIGTERM to the worker then takes 44.6 s to exit, because the worker waits for the background
sleep 45to finish.
In a provisioned environment, a background job would outlive its cancel and delay or block retirement of the generation. The PR text and the design doc both say "foreground run_shell_command". A small guard in ManagedToolExecutor.execute or the route closes this, for example: when toolName === 'run_shell_command' && input.is_background === true, reject it the same way an unadmitted tool is rejected. A test case for it would help too.
Finding 3 — Should fix: requestTimeout = 0 is unnecessary and removes a bound
The comment and the doc say requestTimeout has to be disabled because execute holds the connection until settlement. Node's requestTimeout only bounds how long it takes to receive the request, not the response. Measured in control arm C (identical bundle with main's 5_000):
- A 40 s foreground
executecompleted normally with 200 at 40153 ms. That span crosses Node's 30 s connection-check tick. - An authenticated client that declares
Content-Length: 1000and never sends the body gets 408 at about 20 s on C. On this PR the socket was still open at 50 s.
Unauthenticated stalls are closed after about 2 s in both arms (401). So the only behavior change is that an authenticated slow upload is no longer bounded. Suggested fix: keep 5_000, and remove the rationale from the code comment and from doc §6.
Observations (not blocking)
-
Path confinement is purely Harness-side. The worker happily ran:
read_file /etc/hostswrite_fileto a path outsideworkspaceCwd(the file was created)cd / && pwdin the shell
This matches "no further approval gate", but the tools' own outside-workspace
askis bypassed here. A sentence in §6 saying the Harness admission must include the workspace-boundary decision would make that contract explicit. -
Image reads degrade.
read_fileof a PNG returnsUnsupported image file … This model does not support image inputbecause the worker'sConfiguses the synthetic modelmanaged-runtime-worker. That differs from a local run with a vision model. It's fine for this slice, but worth listing as a follow-up. -
The Java
HttpRuntimeTransport.executeonmaindoes not match the v2 execute body yet. It still posts the prepare-era body (tenantId,workspaceId,sessionId,turnKind,reference). It has notoolNameorinput, a 16 KiB response cap, and a 30 s timeout. This worker would answer that body with 400. Its class comment already defers execute to a later slice. The PR description's "the Java transport speaks it" should be scoped to attestation and the fixtures. -
The
preparedstate can't be reached through HTTP.run()flips the entry toexecutingsynchronously, so thecancel-on-preparedbranch is currently dead code. It's harmless.
Recommendation
Merge after:
- (1) merging
maininto the branch, applying the gate change above, and updating the pinned test and the §3.1 wording - (2) rejecting
is_background: true - (3) restoring
requestTimeout = 5_000
After (1), the PR's suites plus main's shared fixtures all pass against the real worker process (114/114, 13/13).
中文版本
真实环境验证 — PR #12671 @ a5b867e11d
结论:当前状态不可合入。 只要路由能被访问,worker、执行器和日志的行为都很好。但 PR 里带的契约副本早于最终合入的 #12630 版本。把本 PR 合进当前 main 时,git 会静默保留 main 那个只放行 attest 的路由门,结果 execute、status、cancel 全部返回 404。修复只需改 3 行,外加翻转 main 里的一个测试。另有两个较小的问题建议合入前一并修掉(后台 shell 和 requestTimeout,见下文)。
环境
- 代码树:
origin/mainc33e1aad55加 PR head,用git merge合并。5 个 add/add 冲突和内容冲突都按main解决(feat(cli): Declare the v2 execute/status/cancel Managed Runtime contract #12630 已合入,以它为准)。PR 自己的 worker 文件全部干净合入。 - 真实进程:
pnpm install并构建生产 bundle,然后运行node dist/cli.js managed-runtime-worker。boot JSON 从 stdin 传入,worker 打印ready,下文每一次调用都是打到这个进程的真实 HTTP 请求,没有任何 mock。 - 对照臂:
- (A) 直接合并
- (B) A 加候选的路由门修复
- (C) 对照组:B 的 bundle,只把
requestTimeout改回main的5_000 - 另外在 PR 原始 head 上也跑了一遍作为参照。
发现 1 — 阻塞:合入后工具路由不可达
main 上的 #12630 在评审中把 isOwnedManagedRuntimeRoute 收窄成 method === ATTEST_ROUTE.method && url === ATTEST_ROUTE.path。测试 rejects declared tool routes until their handlers land 钉住了这一行为,设计文档 §3.1 也写明「在真实工具 handler 挂载前」路由门一直返回 404。本 PR 正是负责挂载 handler 的那一步,但分支上保留的仍是评审前的 OWNED_MANAGED_RUNTIME_ROUTES.some(...),与合并基点完全相同。因此 git 不报冲突地采用了 main 那一侧,新 handler 被挡在一个永远不放行的路由门后面。
| 代码树 | PR 的 3 个套件 | 真实 worker:execute / status / cancel |
main 的 13 个负面共享 fixture(真实进程) |
|---|---|---|---|
| PR head(原样) | 81/81 ✅ | — | — |
合入 main(A) |
失败 17 / 114 | 404,响应体为空(attest 仍为 200) | 2/13(只有期望 404 的 trailing-path 和 wrong-method 通过) |
| A + 路由门修复(B) | 114/114 ✅ | 200 | 13/13 ✅ |
候选修复见英文部分的 diff(已在 B 臂验证,tsc --noEmit 与 eslint 均无报错)。§3.1 中「在真实工具 handler 挂载前,ownedManagedRuntimeRouteGate 只放行 attestation 路由……」这句的中英文版本也要同步修改。由于本分支仍带着自己那份 #12630 提交,比较实际的收敛方式是把 origin/main merge 进来(有评审 thread,不要 rebase):契约、schema、fixture、Java 和文档文件取 main 的版本,再把文档里的 worker 小节补回去。
验证通过的部分(B 臂,真实进程)
- 已准入工具:
read_file能读到工作区文件。write_file和edit执行后,磁盘上的文件变为alpha\ngamma\n。edit匹配不到时 2 ms 内结算为executionStatus: "error"。即使用的是合成模型名,也不会卡在 LLM 纠错路径上。- 前台 shell 的 cwd 就是已证明的工作区。
- 幂等:
- 同一
callId的两个并发execute都在约 1039 ms 后返回相同结果;结算后的第三次调用 2 ms 返回。副作用计数文件只有 1 行,说明命令只执行了一次。 - 同
callId但摘要不同、或输入不同,都返回 409managed_runtime_identity_conflict。 - 未准入工具(
glob)返回 409。
- 同一
- status 与 cancel:
- 对未见过的 reference,
status和cancel都返回 200unknown。 - 执行中的调用,
status返回executing/lastSequence: 1;结算后返回结果和lastSequence(2,取消后为 3)。 - 对运行中的 shell 调用
cancel返回cancel_requested,挂起的execute在 818 ms 时返回executionStatus: "cancelled",shell 进程已退出。重复 cancel 返回已结算的结果。 - 响应符合合入后的 schema:
execute和cancel不带lastSequence,未结算状态不带result。
- 对未见过的 reference,
- 响应大小: 工具自身会截断,输出远低于 1 MiB 上限:
- 3 MB 文本文件的
read_file→ 25 KB - 1500 行 × 1900 字符 → 25 KB
- 3 MB shell 标准输出 → 4.7 KB
- 3 MB 文本文件的
- 关闭: 前台
execute进行中收到 SIGTERM,520 ms 内退出,shell 子进程被杀掉。
发现 2 — 建议修复:is_background: true 绕过了「仅前台」的准入
执行器准入 run_shell_command 时没有检查 is_background。调用方设置了它之后:
execute立即结算为success,内容是Background shell started … pid: 58430。- 随后的
cancel只返回这个已结算结果,进程继续运行(alive after cancel=true)。 - 之后向 worker 发 SIGTERM,要 44.6 s 才退出,因为 worker 要等后台的
sleep 45结束。
在已供应的环境里,后台任务会在 cancel 之后继续存活,并拖慢甚至阻塞该代的回收。PR 描述和设计文档写的都是「前台 run_shell_command」。在 ManagedToolExecutor.execute 或路由层加一个小守卫即可:当 toolName === 'run_shell_command' && input.is_background === true 时,按未准入工具的方式拒绝。建议同时补一个测试用例。
发现 3 — 建议修复:requestTimeout = 0 没有必要,还去掉了一道上限
代码注释和文档说,因为 execute 会持有连接直到结算,所以必须停用 requestTimeout。但 Node 的 requestTimeout 只限制接收请求的时长,不限制响应。在对照臂 C(同一 bundle,用 main 的 5_000)实测:
- 40 s 的前台
execute正常完成,40153 ms 返回 200。这段时间跨过了 Node 30 s 的连接检查周期。 - 已鉴权客户端声明
Content-Length: 1000却不发请求体:C 臂约 20 s 返回 408;本 PR 下 socket 到 50 s 仍未关闭。
两臂中未鉴权的停滞请求都在约 2 s 后关闭(401)。因此唯一的行为变化是:已鉴权的慢上传不再受限制。建议保留 5_000,并删掉代码注释和文档 §6 里的这条理由。
其他观察(不阻塞)
-
路径约束完全由 Harness 侧负责。 worker 照常执行了:
read_file /etc/hostswrite_file写到workspaceCwd之外(文件确实被创建)- shell 里
cd / && pwd
这符合「不再有审批门」的设计,但工具自带的工作区外
ask在这里被绕过了。建议在 §6 写明:Harness 的准入必须包含工作区边界判定,让这条契约更明确。 -
读图片会降级。 由于 worker 的
Config用的是合成模型managed-runtime-worker,read_file读 PNG 返回Unsupported image file … This model does not support image input,与本地使用视觉模型时的行为不同。本切片可以接受,但值得列为后续项。 -
main上 JavaHttpRuntimeTransport.execute发的还不是 v2 execute 的请求体。 它仍然发 prepare 时代的请求体(tenantId、workspaceId、sessionId、turnKind、reference),没有toolName/input,响应上限 16 KiB,超时 30 s。这个 worker 会对它返回 400。它的类注释已说明 execute 属于后续切片,PR 描述里「Java transport 也会说了」这句应限定为 attestation 和 fixture。 -
HTTP 路径上到不了
prepared状态。run()会同步把条目切到executing,所以cancel处理prepared的分支目前是死代码。无害。
建议
完成以下三项后可以合入:
- (1) 把
mainmerge 进分支,应用上面的路由门修改,同时更新被钉住的测试和 §3.1 的措辞 - (2) 拒绝
is_background: true - (3) 恢复
requestTimeout = 5_000
完成 (1) 后,PR 的套件加上 main 的共享 fixture 在真实 worker 进程上全部通过(114/114、13/13)。
|
@qwen-code /resolve |
|
Qwen Code attempted to resolve merge conflicts but the run did not complete successfully. address-summary.mdRoot causeThis PR is stacked on the branch copy of #12630, which merged to main independently as Textual vs semanticTextual — 6 files: the v2 Semantic — 2 files (both design docs): main rewrote §2–§5, our commit appended §6. Resolved as main's body + our §6, seam byte-verified in EN and zh-CN. Load-bearing — the merge is NOT greenMain's #12630 narrowed the raw gate to attest-only because the v2 routes were declared but unmounted: return method === ATTEST_ROUTE.method && url === ATTEST_ROUTE.path;This PR mounts execute/status/cancel behind that same gate ( return OWNED_MANAGED_RUNTIME_ROUTES.some(
(route) => method === route.method && url === route.path,
);Casualties in NON-conflicted files
No build/typecheck/test run. Imports, executor core paths and the 66 new (200-status, skipped) fixtures check out. 中文说明根因:本 PR 叠在 #12630 的分支副本上,而 #12630 经评审改写后已以 文本型(6 文件):v2 schema/fixtures、TS 与 Java 的契约/一致性测试、两个 broker Java 文件。我方与评审前提交逐字节相同,main 为严格超集(含我方 语义型(2 文件):两份设计文档,main 改写 §2–§5、我方追加 §6;合并为「main 正文 + 我方 §6」,中英文接缝逐字节校验。合并后 关键风险:结果不是绿的。 main 因路由「已声明未挂载」把 raw gate 收窄为仅放行 attest;本 PR 把 execute/status/cancel 挂在同一 gate 之后( 受影响的非冲突文件: Check the workflow run for full logs. |
|
Addressed the conflict-resolution and verification comments in 1cecf1a. Main was merged into the existing branch without rewriting its review history. The reviewed schema, fixtures, and Java broker changes were preserved. The three reported defects are fixed: all mounted tool routes pass the exact HTTP gate; background shell calls are rejected before a process or journal entry is created; and the 5-second request-receipt timeout remains enabled independently of execution duration. Both design languages and the PR description now accurately describe Harness-owned workspace boundaries, image limitations, deferred Java tool transport, and the internal-only prepared state. Open-ended and reverse audits also found and reproduced three additional defects, now fixed: shared file-read caching hid content from another session; tool parameter normalization mutated the journal identity and rejected identical retries; and JSON-escaped SVG output exceeded the 1 MiB contract. The worker now disables conversation-dependent caching, executes with an input copy, and journals a bounded terminal error for oversized results so execute, status, cancel, and replay stay consistent. Regression tests were observed failing before each fix. Validation on macOS, Node 22.22.2 and Java 21:
The global qwen 0.24.4 installation lacked this worker command, so baseline reproduction used a real source-worker process; final verification used only the freshly built production CLI. Windows and Linux were not run locally. 中文说明已在 1cecf1a 合入主干并处理冲突及评论,保留评审历史和主干已合入的 schema、fixtures、Java broker 更新。原评论的路由不可达、后台 shell 越过准入、错误关闭请求接收超时均已修复;文档和 PR 描述同步说明 Harness 边界责任、图片能力限制、Java 工具 transport 后续范围及 prepared 内部状态。 多轮无方向和反向审计还发现并实证了跨会话读取缓存、参数归一化污染幂等身份、SVG JSON 响应超过 1 MiB 三项缺陷,均已修复并补上先失败后通过的回归测试。最终完整构建、打包、类型检查、lint、格式检查及提交钩子通过,提交内容与已审计版本逐字节一致。CLI 121 项、Java 96 项测试通过;生产 worker 真实 HTTP 验证覆盖六项缺陷、正常工具行为和 13 个负面 fixtures。慢上传 28,799 ms 返回 408,40 秒前台命令 40,078 ms 成功;SVG 四条查询路径均返回同一有界错误,大小为 160/177/160/160 bytes。修复后连续两轮完整审计未发现新问题,其中包含独立复核。 全局 qwen 0.24.4 缺少此 worker 命令,因此基线使用真实源码 worker 进程,最终验证仅使用重新构建的生产 CLI。本地仅验证 macOS,未运行 Windows/Linux。 |
Real-environment re-verification (round 2) — PR #12671 @
|
| # | Round 1 | Round 2 result |
|---|---|---|
| 1 | After merge, execute/status/cancel returned 404 |
✅ Fixed. Routes are live. 3 suites pass 121/121. main's 13 negative shared fixtures pass 13/13 against the real process. The gate test now also pins GET, a trailing /, a query string, and an unlisted /v2/prepare as 404. |
| 2 | is_background: true escaped the journal and cancel |
true is rejected with 409 and no journal entry or process is created. The string "true" still gets through (details below). |
| 3 | requestTimeout = 0 removed the upload bound |
✅ Fixed. Back to 5_000. A 40 s foreground execute still completes (40055 ms, 200). An authenticated stalled body gets 408 at about 16 s (it stayed open past 50 s in round 1). |
Retested with no regressions:
read_file,write_file, andeditwork; the file on disk ends up asalpha\ngamma\n.- Concurrent duplicate calls join, and the side-effect file has 1 line.
- A different digest, a different input, or an unadmitted tool each return 409.
cancelon a running shell settles ascancelledin 830 ms, and the process is gone.- SIGTERM during a foreground execute exits in 516 ms.
- Large outputs are truncated by the tools themselves (3 MB → 25 KB / 4.7 KB). The new 1 MiB journal cap has its own unit test (
journals a bounded error when JSON encoding exceeds the result limit).
Finding — the string "true" bypasses the background-shell guard
The guard compares the raw input: input['is_background'] === true. But tool.build() runs core's schemaValidator, whose fixBooleanValues pass converts "true"/"false" (case-insensitive) to booleans when the schema accepts boolean (packages/core/src/utils/schemaValidator.ts:1120-1136). So the tool runs with is_background: true while the guard sees a string. On the real worker:
is_background |
PR 1cecf1a228 |
Candidate fix |
|---|---|---|
true |
409, status → unknown, no process |
409, unknown, no process |
"true" |
200 Background shell started … pid 72102; the process is alive; cancel returns the settled result and the process stays alive; SIGTERM waits 44.5 s for it |
409, unknown, no process |
1 / "yes" |
schema rejects it (params/is_background must be boolean), no process |
same |
A Harness that relays model-produced arguments can easily send "true", since models emit string booleans often enough that the validator has this coercion pass for them. The PR's test uses only the boolean true. With the test extended to [true, 'true', 'TRUE'], the PR's executor fails the 2 string cases, and the candidate fix passes all 123.
Candidate fix: decide on the parameters the tool will actually run with. It is verified with tsc --noEmit and eslint, both clean.
--- a/packages/cli/src/serve/managed-runtime-tool-executor.ts
+++ b/packages/cli/src/serve/managed-runtime-tool-executor.ts
@@ export class ManagedToolExecutor {
- if (toolName === ShellTool.Name && input['is_background'] === true) {
+ if (toolName === ShellTool.Name && isBackgroundShell(tool, input)) {
throw new ManagedToolConflictError(
'Managed Runtime does not admit background shell execution.',
);
@@
+// Decide on the parameters the tool will actually run with: build() coerces
+// "true"/"false" strings to booleans, so the raw input is not authoritative.
+function isBackgroundShell(
+ tool: AnyDeclarativeTool,
+ input: Record<string, unknown>,
+): boolean {
+ try {
+ const params = tool.build(structuredClone(input)).params as {
+ is_background?: unknown;
+ };
+ return params.is_background === true;
+ } catch {
+ return false;
+ }
+}If build() throws, the call still goes through the existing path, which settles it as error without spawning anything. The test change turns the existing case into it.each([true, 'true', 'TRUE'])('rejects background shell execution (is_background=%j) without recording an invocation', …). The body is unchanged.
Minor (not blocking)
- A rejected background call returns the generic
Managed Runtime invocation identity conflicts., because the route maps everyManagedToolConflictErrorto that one message. The executor's more specific text never reaches the caller. It's harmless, but the specific text would be easier to diagnose. - The round-1 observations still apply unchanged, and the doc now scopes them: path confinement is Harness-side, and image reads degrade under the synthetic model name.
Recommendation
Merge after the isBackgroundShell change and the extended test. Everything else from round 1 is verified fixed on the real worker.
中文版本
真实环境复验(第 2 轮)— PR #12671 @ 1cecf1a228
结论:第 1 轮的三个问题中两个已修复,已在真实 worker 进程上确认。后台 shell 守卫仍可被绕过:is_background: "true"(字符串)会起一个后台进程,cancel 停不掉它,worker 关闭时还要等它约 44 s。 下面给出 16 行的候选修复和测试扩展,二者都在单测和真实进程上做过 A/B 验证。这一项合入后,我这边没有剩余阻塞项。
环境
- 代码树: PR head
1cecf1a228,在本地与当前origin/main64c0453824合并。合并无冲突,分支状态为MERGEABLE。 - 真实进程:
pnpm install并构建生产 bundle,然后运行node dist/cli.js managed-runtime-worker,boot JSON 从 stdin 传入,所有调用都是真实 HTTP。探针与第 1 轮相同,另加了新的输入变体。 - 每次运行前都确认过 bundle chunk 里包含新代码(
OWNED_MANAGED_RUNTIME_ROUTES.some、requestTimeout = 5e3,以及后台守卫的错误文案)。
第 1 轮问题的复验
| # | 第 1 轮 | 第 2 轮结果 |
|---|---|---|
| 1 | 合入后 execute/status/cancel 返回 404 |
✅ 已修复。 路由可用;3 个套件 121/121 通过;main 的 13 个负面共享 fixture 在真实进程上 13/13 通过。路由门测试现在还钉住了 GET、结尾 /、查询串、未列出的 /v2/prepare 都返回 404。 |
| 2 | is_background: true 绕过日志和 cancel |
true 会被 409 拒绝,不创建日志条目也不起进程。字符串 "true" 仍然能通过(见下文)。 |
| 3 | requestTimeout = 0 去掉了上传时长上限 |
✅ 已修复。 恢复为 5_000。40 s 的前台 execute 仍正常完成(40055 ms,200);已鉴权但停滞的请求体约 16 s 收到 408(第 1 轮时超过 50 s 仍未关闭)。 |
复测未发现回归:
read_file、write_file、edit正常,磁盘上的文件最终为alpha\ngamma\n。- 并发的重复调用会合并,副作用文件只有 1 行。
- 摘要不同、输入不同或工具未准入,都返回 409。
- 对运行中的 shell 调用
cancel,830 ms 结算为cancelled,进程已退出。 - 前台 execute 进行中收到 SIGTERM,516 ms 退出。
- 大输出由工具自身截断(3 MB → 25 KB / 4.7 KB)。新增的 1 MiB 日志上限有专门的单测(
journals a bounded error when JSON encoding exceeds the result limit)。
发现:字符串 "true" 能绕过后台 shell 守卫
守卫比较的是原始输入:input['is_background'] === true。但 tool.build() 会经过 core 的 schemaValidator,其中 fixBooleanValues 这一步会在 schema 接受 boolean 时,把 "true"/"false"(不区分大小写)转成布尔值(packages/core/src/utils/schemaValidator.ts:1120-1136)。结果是工具以 is_background: true 运行,而守卫看到的是字符串。真实 worker 上的表现:
is_background |
PR 1cecf1a228 |
候选修复 |
|---|---|---|
true |
409,status → unknown,无进程 |
409,unknown,无进程 |
"true" |
200 Background shell started … pid 72102;进程存活;cancel 返回已结算结果,进程仍存活;SIGTERM 要等它 44.5 s |
409,unknown,无进程 |
1 / "yes" |
被 schema 拒绝(params/is_background must be boolean),无进程 |
相同 |
Harness 如果转发模型生成的参数,很容易发出 "true":模型输出字符串形式的布尔值足够常见,校验器正是为此才有这一步转换。PR 的测试只用了布尔 true。把测试扩展为 [true, 'true', 'TRUE'] 后,PR 的执行器在 2 个字符串用例上失败,候选修复 123 个全部通过。
候选修复(见英文部分 diff):按工具实际运行时的参数来判断,即检查 build() 之后的 params.is_background。tsc --noEmit 和 eslint 均无报错。如果 build() 抛错,调用仍走原有路径,结算为 error,不会起进程。测试改动是把原用例改成 it.each([true, 'true', 'TRUE']),用例主体不变。
次要(不阻塞)
- 被拒的后台调用返回的是通用文案
Managed Runtime invocation identity conflicts.,因为路由层把所有ManagedToolConflictError都映射成这一条,执行器里更具体的文案到不了调用方。无害,但换成具体文案更便于排查。 - 第 1 轮的观察仍然成立,文档现在已经写明了它们的边界:路径约束由 Harness 侧负责;合成模型名下读图片会降级。
建议
应用 isBackgroundShell 修改并扩展测试后即可合入。第 1 轮的其余问题都已在真实 worker 上确认修复。
|
Addressed the round-2 review and merged main 688e8af in be47e55. The previous assumption that non-boolean background inputs would all fail validation was incorrect. Admission now uses the same parameter normalization as the shell tool: boolean true and case-insensitive string true are rejected before journaling or process creation; boolean/string false remains foreground. The response now carries the specific background-admission error. Pre-commit open-ended and reverse audits also exposed two deep-input error paths. Copy/validation failures now retain their recorded error settlement, and inputs the JSON encoder cannot represent are rejected with 400 before journaling. Accepted input is encoded once and retained for retry comparison, so replay cannot fail while re-encoding a recorded payload. Both design languages describe these boundaries. Main advanced during verification and introduced conflicts in the two design documents. These are resolved while preserving both the worker implementation and the newly landed Java tool transport from #12637. Broker transport wiring remains follow-up work; the PR description has been updated accordingly. Validation on the final merged tree (macOS, Node 22.22.2, Java 21):
中文说明已在 be47e55 处理第 2 轮评论并合入最新 main 688e8af。此前“非布尔输入都会被拒绝”的判断有误;现在按真实工具参数归一化后的结果准入,布尔 true 和各种大小写字符串 true 均在建立日志或启动进程前拒绝,false 仍以前台执行,并返回具体拒绝原因。 提交前无方向和反向审计发现的两处深层输入错误路径也已修复:复制/校验异常保留可回放的已结算错误;无法 JSON 编码的输入在建立日志前稳定返回 400;已准入输入只编码一次用于重试比较。期间主干新合入 #12637,产生的中英文设计文档冲突已解决,保留 worker 与 Java transport 均已实现、Broker 接线仍为后续工作的准确范围。 最终合并版本完整构建、打包、类型检查、lint、格式检查、Java Checkstyle 通过;CLI 135 项、Java 144 项通过。重新构建的生产 worker 四套真实 HTTP 验证全部通过,覆盖参数变体、幂等、文件操作、取消、13 个负面 fixtures 与深层输入。慢上传 28,822 ms 返回 408,40 秒前台命令 40,039 ms 成功。最终合并版本连续两轮完整审计未发现新问题,其中包含独立复核;提交钩子后核对提交树与已审计树一致。 |
Real-environment re-verification (round 3) — PR #12671 @
|
is_background |
Real worker result |
|---|---|
true, "true", "True", "tRuE" |
409 Managed Runtime does not admit background shell execution. (the specific message now reaches the caller); status → unknown; no process |
1, "yes", " true" |
200, settled as error (params/is_background must be boolean); no process |
"false", "FALSE" |
200 success, ran in the foreground (held the connection for the full 30 s) |
Mutation check: reverting the guard to the raw input['is_background'] === true turns 3 PR tests red ("true"/"TRUE"/"TrUe"). The new test does detect the bypass.
New in this round: deep and unencodable input
- A 4000-level nested input returns the same recorded
200 settled/erroracrossexecute, retry,status, andcancel, with no command side effect. - A 10000-level input returns
400 managed_runtime_attestation_invalidon both execute and retry, andstatus/cancelreturnunknown, so nothing was journaled. There is no side effect, and the worker still servesread_fileafterwards. - Mutation check: if the
JSON.stringifyfailure no longer throwsManagedToolInvalidError, 2 PR tests go red.
Rounds 1–2 regression pass (real process)
- Routes are live, and
main's 13 negative shared fixtures pass 13/13. read_file,write_file, andeditwork.- Concurrent duplicate calls join, and the side-effect file has 1 line.
- A different digest, a different input, or an unadmitted tool each return 409.
cancelon a running shell settles ascancelledin 809 ms, and the process is gone.- A 40 s foreground execute succeeds (40055 ms) with
requestTimeout = 5_000. An authenticated stalled body gets 408 at about 16.8 s. - SIGTERM during an execute exits in 515 ms, and the shell child is killed.
- Large outputs are still truncated by the tools (3 MB → 25 KB / 4.7 KB).
- The 3 suites pass 135/135.
tsc --noEmitand eslint are clean.
Notes (not blocking)
- The round-1 observations are unchanged and now documented as out of scope: path confinement is Harness-side, image reads degrade under the synthetic model name, and broker transport wiring is follow-up work.
中文版本
真实环境复验(第 3 轮)— PR #12671 @ be47e55274
结论:从验证角度看可以合入。 第 2 轮发现的后台 shell 绕过已经关闭,新增的非法输入处理路径行为与描述一致。此前各轮的问题都保持修复状态,真实 worker 进程上没有发现回归。
环境
- 代码树: PR head
be47e55274,在本地与当前origin/main90232f0eb0合并,无冲突。 - 真实进程: 重新
pnpm install并构建生产 bundle,然后运行node dist/cli.js managed-runtime-worker,boot JSON 从 stdin 传入,所有调用都是真实 HTTP。 - 运行前确认过 bundle chunk 里包含新的准入代码(
validateToolParams(params) === null)和requestTimeout = 5e3。
第 2 轮问题:字符串形式的 is_background
现在的准入逻辑用 shell 工具自己的 validateToolParams 校验一份 structuredClone 过的输入。BaseDeclarativeTool.build() 就是在同一个对象上先 validateToolParams(params) 再 createInvocation(params),而 ShellTool 没有重写 build。因此准入看到的参数,和实际执行时用的是同一份归一化结果。
is_background |
真实 worker 上的结果 |
|---|---|
true、"true"、"True"、"tRuE" |
409 Managed Runtime does not admit background shell execution.(具体原因现在能传到调用方);status → unknown;无进程 |
1、"yes"、" true" |
200,结算为 error(params/is_background must be boolean);无进程 |
"false"、"FALSE" |
200 success,按前台执行(连接一直保持到命令跑完 30 s) |
变异检查:把守卫改回比较原始输入 input['is_background'] === true,PR 有 3 个测试变红("true"/"TRUE"/"TrUe"),说明新测试确实能发现这个绕过。
本轮新增:深层嵌套与无法编码的输入
- 4000 层嵌套输入:
execute、重试、status、cancel都返回同一个已记录的200 settled/error,命令没有产生副作用。 - 10000 层嵌套输入:execute 和重试都返回
400 managed_runtime_attestation_invalid,status/cancel返回unknown,说明没有建立日志条目。没有副作用,之后 worker 仍能正常处理read_file。 - 变异检查:如果
JSON.stringify失败时不再抛出ManagedToolInvalidError,PR 有 2 个测试变红。
第 1–2 轮回归复测(真实进程)
- 路由可用,
main的 13 个负面共享 fixture 13/13 通过。 read_file、write_file、edit正常。- 并发的重复调用会合并,副作用文件只有 1 行。
- 摘要不同、输入不同或工具未准入,都返回 409。
- 对运行中的 shell 调用
cancel,809 ms 结算为cancelled,进程已退出。 requestTimeout = 5_000下,40 s 的前台 execute 正常成功(40055 ms);已鉴权但停滞的请求体约 16.8 s 收到 408。- execute 进行中收到 SIGTERM,515 ms 退出,shell 子进程被杀掉。
- 大输出仍由工具自身截断(3 MB → 25 KB / 4.7 KB)。
- 3 个套件 135/135 通过,
tsc --noEmit和 eslint 均无报错。
备注(不阻塞)
- 第 1 轮的观察没有变化,现在文档已把它们写明为不在本 PR 范围内:路径约束由 Harness 侧负责、合成模型名下读图片会降级、Broker transport 接线属于后续工作。
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Independent Critical-only review — head be47e552
Mounts the v2 execute/status/cancel operations on the Managed Runtime worker: two new modules (managed-runtime-tool-routes.ts +237, managed-runtime-tool-executor.ts +329), small changes to the attestation contract and worker, 725 lines of route tests, and design-doc updates.
No historical blocker
No review threads, no CHANGES_REQUESTED, no bot ledger and no Critical recorded on any surface. A maintainer approval stands at this head. This review rests on my own read of the diff.
Authorization precedes body parsing on all three routes
Each route is mounted with the same chain, in this order:
managedRuntimeNoStore → authorizeManagedRuntime(identity) → express.json({ inflate: false, limit: <declared>, strict: true, type: 'application/json' }) → handler → handleManagedRuntimeJsonError
Two properties matter and both hold. An unauthenticated request is rejected before any body is read, so the byte limit cannot be used as an amplification vector by a caller who has not already attested. And inflate: false keeps the limit on wire bytes rather than decompressed bytes, so a small compressed payload cannot expand past it — the same reasoning the attestation route's existing comment gives, now applied consistently. strict: true rejects primitive JSON and type: 'application/json' refuses other content types, so the handler never sees a shape it did not ask for.
The middleware is exported from the attestation contract rather than reimplemented, so all four owned routes share one no-store, one authorization check and one JSON error handler. That is the right direction for a trust boundary: there is one implementation to get correct, not two to keep in step. Paths and limits are read from the frozen OWNED_MANAGED_RUNTIME_ROUTES table filtered to exclude attest, so no literal can drift from the declaration.
isOwnedManagedRuntimeRoute was generalized from a single method-and-path equality into OWNED_MANAGED_RUNTIME_ROUTES.some(route => method === route.method && url === route.path). That generalization is load-bearing rather than cosmetic: the worker wraps the app in ownedManagedRuntimeRouteGate(app), so without it the three new routes would not be recognised as owned by the gate that decides what this server exposes. Exact equality on both method and path is retained, so the widening admits the declared routes and nothing else. The 413 message also had to stop claiming "16 KiB" now that limits differ per route, and it now says "exceeds its body size limit".
Request validation is closed-world
parseClosedBody requires protocolVersion === 2 exactly, rejects any key outside the declared required-plus-optional set, and requires every required key. parseReference goes further and demands an exact key set: it sorts the object's keys and compares them element-by-element against the frozen sorted REFERENCE_KEYS (argsDigest, callId, promptId, sessionId) after checking the count, then requires all four to be non-empty strings. A reference carrying an extra field is rejected rather than ignored. afterSequence, the one optional field, must be a Number.isSafeInteger and non-negative, which rules out NaN, infinities, fractions and negatives.
Rejections return a fixed 400 — managed_runtime_attestation_invalid with the constant message Managed Runtime tool request is invalid. — so no caller-supplied value is echoed back and the response does not disclose which check failed. The single non-constant message is the 409 for ManagedToolConflictError, which carries error.message; that string is produced by server-side code rather than derived from the request, so it is not a reflection path. Anything that is neither invalid nor conflict is rethrown to the shared handleManagedRuntimeJsonError, so an unexpected failure is handled by the same terminal handler as the attestation route instead of being swallowed or left to Express's default.
executor.hasTool(toolName) is checked before execution and answers 409 managed_runtime_identity_conflict with "Managed Runtime does not admit this tool.", so an unadmitted tool never reaches the executor.
The settled-only invariant holds at all three layers
status and cancel both answer { protocolVersion: 2, state: 'unknown' } when the executor has no view, and otherwise include result only when state === 'settled':
...(view.state === 'settled' ? { result: view.result } : {})The executor's own view builder does the same at managed-runtime-tool-executor.ts:261. That matters because the consuming side enforces the complement as a hard validation: the broker's absorbRuntimeStatus rejects a status whose "settled".equals(state) disagrees with containsKey("result"), and treats unknown as evidence of nothing — never as proof the call did not run. Producer and consumer therefore agree on both limbs of the contract, and a result attached to a non-terminal state, or a settled answer without one, cannot be emitted by this route.
The executor is scoped to the worker's own workspace and does not shell out
ManagedToolExecutor.forWorkspace(boot.workspaceCwd, boot.runtimeInstanceId) is constructed in the worker from the boot payload, not from any request field, and binds targetDir and cwd to that workspace. The routes are registered with the same boot identity as the attestation route, so both are authorized against the same attested material.
I assessed the executor's 329 lines structurally rather than line by line, and the surface is narrower than the size suggests: it contains no spawn, exec, execFile or child_process use, and no writeFile, rmSync, unlink, realpath, resolve or join, so it neither shells out nor manipulates paths itself. Execution goes through AnyDeclarativeTool instances held in a Map and admitted by hasTool, with an in-memory JournalEntry map backing status. Shutdown is wired correctly: close() now chains executor.close() before server.close(...)/closeAllConnections(), still memoised through closing ??= so it stays idempotent, and the existing hardening (x-powered-by disabled, maxHeadersCount = 32, headersTimeout = 5_000) is untouched.
Scope disclosure and CI
I read the routes module, the contract diff and the worker diff in full. For the executor I read its structure, its workspace binding, its settled-only view builder and its absence of process and filesystem surface; I did not read its journal transitions, tool-error mapping or close() body line by line, and I did not read the 725-line test file case by case. Per Critical-only scope a missing test is not a blocker. review-pr and web-shell E2E Smoke were in progress at head with no failure attributable to this diff; CI state is not a gate.
Verdict: APPROVE — No Critical found. Authorization precedes body parsing on every route with inflate: false and declared limits, request validation is closed-world down to an exact reference key set, error responses do not echo caller input, the tool-admission gate runs before execution, the settled-only result invariant matches what the broker validates on the consuming side, and the executor is bound to the worker's own attested workspace while exposing no process or filesystem surface of its own.
…s-p0-p8 Brings in main through ab61e04. QwenLM#12671 mounts the v2 tool operations on main's attestation worker; the branch's Runtime path starts its own dist/managed-runtime-worker.js entry, so the two stay independent.
wenshao
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — the packages/cli test suite never executed: CI test/integration checks were skipped (PR merged) and Agent 7's local build of packages/cli timed out (infrastructure), so test-pass status is unverified by execution.
Not explored to full depth (tool budget reached): "agent 1b": 无(工具用量约 20 次,远低于 66 次上限;上述所有检查均已完成,无被截断项)。; "agent reverse-audit (round 2)": ChatRecordingService 's constructor was not traced for eager filesystem/timer side effects — chatRecording defaults to true (config.ts:3360 this.chatRecordi…; "agent reverse-audit (round 5)": did not execute the three §4-named vitest files myself at the ceiling; §6's validation paragraph was verified by reading managed-runtime-tool-worker.test.ts (…; "agent reverse-audit (round 5)": did not open managed-runtime-tool-v2.fixtures.json or the shared schema to confirm §3.4's fixture-coverage sentence (five states, four execution statuses, clo…; "agent reverse-audit (round 3)": did not read ShellTool.validateToolParamValues (packages/core/src/tools/shell.ts:5768-5830) to confirm the admission pre-check is side-effect-free — i.e. that….
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):build-and-test — the packages/cli test suite never executed: CI test/integration checks were skipped (PR merged) and Agent 7's local build of packages/cli timed out (infrastructure), so test-pass status is unverified by execution.
未探索到全部深度(达到工具调用预算):"agent 1b":无(工具用量约 20 次,远低于 66 次上限;上述所有检查均已完成,无被截断项)。;"agent reverse-audit (round 2)":ChatRecordingService 's constructor was not traced for eager filesystem/timer side effects — chatRecording defaults to true (config.ts:3360 this.chatRecordi…;"agent reverse-audit (round 5)":did not execute the three §4-named vitest files myself at the ceiling; §6's validation paragraph was verified by reading managed-runtime-tool-worker.test.ts (…;"agent reverse-audit (round 5)":did not open managed-runtime-tool-v2.fixtures.json or the shared schema to confirm §3.4's fixture-coverage sentence (five states, four execution statuses, clo…;"agent reverse-audit (round 3)":did not read ShellTool.validateToolParamValues (packages/core/src/tools/shell.ts:5768-5830) to confirm the admission pre-check is side-effect-free — i.e. that…。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
— qwen3.8-max via Qwen Code /review (v0.24.6)
| 'Managed Runtime tool request is invalid.', | ||
| ); | ||
| } | ||
| const existing = this.entries.get(reference.callId); |
There was a problem hiding this comment.
[Critical] R1-2: [fails-closed] [new-surface] The invocation journal is keyed on reference.callId alone, but sameInvocation/sameReference require the full 4-tuple (sessionId, promptId, callId, argsDigest). One worker serving multiple Runtime sessions permanently rejects a second session that reuses a callId.
callId is only unique per conversation (toolCallIdUtils.nextGeneratedId emits call_qwen_N from each session's own usedIds). The worker is built to serve multiple sessions (design §6: "can serve multiple Runtime sessions"; this PR's own test returns file contents for separate calls across sessions drives one worker with session-a and session-b, dodging the collision only by using different callIds). When session-b sends {sessionId:'session-b', callId:'call_qwen_1'}, entries.get('call_qwen_1') hits session-a's entry, sameReference is false, and execute throws ManagedToolConflictError → 409 managed_runtime_identity_conflict, which the Java transport marks non-retryable. Entries are never evicted, so that callId is poisoned for the whole generation; session-b's status/cancel return 200 unknown and its call can never succeed.
Witness (real executor, unmodified PR code):
session-a first execute: success [{type:text,text:"file contents"}]
session-b same callId+promptId+digest, different sessionId: threw ManagedToolConflictError "Managed Runtime invocation identity conflicts."
session-b status lookup: null session-b cancel lookup: null
grep 'entries.(delete|clear|size)' -> 0 matches (never evicted)
Suggested fix: key the journal on the full call identity (e.g. `${sessionId}\u0000${promptId}\u0000${callId}`) at the get/set/lookup sites; keep sameInvocation comparing argsDigest and inputJson.
The composite key must not include argsDigest — ToolExecutionRecord.java:71-78 treats it as part of the Broker-side record identity, so folding it in would turn a same-callId/different-digest retry into a second real execution (double side effect); keep that case a 409 via sameInvocation.
Add a worker case: same callId+argsDigest, sessionId session-a then session-b, each executing run_shell_command appending to calls.txt; assert both settle success and calls.txt has two lines — reverting the key to reference.callId must turn it red.
中文说明
调用日志仅以 reference.callId 为键,但 sameInvocation/sameReference 要求完整四元组(sessionId、promptId、callId、argsDigest)。一个 worker 服务多个 Runtime 会话时,复用同一 callId 的第二个会话会被永久拒绝。
callId 只在单会话内唯一(toolCallIdUtils.nextGeneratedId 从每个会话自己的 usedIds 生成 call_qwen_N)。worker 的设计就是服务多会话(设计文档 §6:"can serve multiple Runtime sessions";本 PR 自带测试 returns file contents for separate calls across sessions 用 session-a/session-b 打同一个 worker,只是刻意用了不同 callId 才没撞上)。当 session-b 发送 {sessionId:'session-b', callId:'call_qwen_1'} 时,entries.get('call_qwen_1') 命中 session-a 的条目,sameReference 为假,execute 抛 ManagedToolConflictError → 409(Java 侧不可重试)。日志条目从不淘汰,该 callId 在整个 generation 内被永久毒化;session-b 的 status/cancel 返回 200 unknown,其调用永远无法成功。
见证(真实 executor,未修改的 PR 代码):
session-a first execute: success [{type:text,text:"file contents"}]
session-b same callId+promptId+digest, different sessionId: threw ManagedToolConflictError "Managed Runtime invocation identity conflicts."
session-b status lookup: null session-b cancel lookup: null
grep 'entries.(delete|clear|size)' -> 0 matches (never evicted)
建议修复: 日志主键改为完整调用身份(如 `${sessionId}\u0000${promptId}\u0000${callId}`),在 get/set/lookup 各处统一使用;sameInvocation 仍比较 argsDigest 与 inputJson。
复合键不能包含 argsDigest——ToolExecutionRecord.java:71-78 把它当作 Broker 侧记录身份的一部分,纳入会让"同 callId、不同 digest"的重试退化成第二次真实执行(副作用翻倍);该情形应继续由 sameInvocation 产出 409。
新增 worker 用例:同一 callId+argsDigest、sessionId 分别为 session-a/session-b,各执行一次 run_shell_command 向 calls.txt 追加;断言两次都结算成功且 calls.txt 有两行——把主键改回 reference.callId 后该用例必须变红。
— qwen3.8-max via Qwen Code /review (v0.24.6)
| const result: ToolResult = await invocation.execute( | ||
| entry.controller.signal, | ||
| ); | ||
| payload = toPayload(result, ManagedToolExecutor.isCancelRequested(entry)); |
There was a problem hiding this comment.
[Critical] R1-15: [certifies-falsely] [new-surface] toPayload decides executionStatus:'cancelled' from the cancel_requested journal flag alone, so a tool that already committed its side effect (the cancel lost the race) is published to the Broker as cancelled while carrying its real success result.
A cancel landing inside shell.ts's post-exit attribution window (trackSessionCommit / attachCommitAttribution / truncateToolOutput, measured ~25-75ms after the command exits 0) sets cancel_requested; run() then settles a command that already committed as cancelled. The Broker persists that as terminal (ToolExecutionRecord.java:110 forces the record status to equal the result status; settleExecution never revisits a settled record), so the model is told the call was cancelled and re-issues it — a duplicate git commit / curl -X POST / migration. §6 blesses only "a cancel the Runtime honored ... whether the tool surfaces the abort as an error or as an early result"; this is the sibling state where the tool never surfaced the abort (Exit Code: 0, Error: (none)). Core resolves the same race the other way (shell.ts:2966-2975 wasPromoteRefused: "report what actually happened ... rather than as a cancellation").
Witness (rate sweep, real executor, git commit --allow-empty, cancel at delays 0-400ms ×3):
8/27 rows settled executionStatus:"cancelled" with commitsDelta:1 (the commit landed)
row delay:50 -> {"executionStatus":"cancelled","commitLanded":true,
text:"...Output: [master 580cd93] msg-10 ... Exit Code: 0 ... Signal: (none)"}
Suggested fix: classify the terminal status from the execution's own outcome, not the journal flag alone — publish cancelled only when the tool actually surfaced the abort, otherwise publish the real success/error. (Or fix at the source: shell.ts aborted = abortSignal.aborted && !exited, using the existing exited/recordedExit flags.)
The fix must still publish cancelled for a polite early result carrying no error (§6), so it cannot key off result.error alone.
Add a worker case that cancels an invocation whose tool completes despite the abort (a small write_file, or a command that already exited) and asserts executionStatus:'success' while the side effect exists; today's flag-only mapping reports cancelled and goes red.
中文说明
toPayload 仅凭 cancel_requested 日志标志判定 executionStatus:'cancelled',因此一个已经提交副作用的工具(取消晚到一步)会以 cancelled 发布给 Broker,却携带它真实的 success 结果。
落在 shell.ts 退出后归因窗口内的 cancel(trackSessionCommit / attachCommitAttribution / truncateToolOutput,实测在命令以 0 退出后约 25-75ms)会置 cancel_requested;run() 随即把一个已经提交副作用的命令结算为 cancelled。Broker 将其作为终态持久化(ToolExecutionRecord.java:110 强制记录状态等于结果状态;settleExecution 从不重访已结算记录),于是模型被告知调用被取消并重发——重复的 git commit / curl -X POST / 迁移。§6 只祝福"Runtime 兑现的取消……无论工具把 abort 表现为 error 还是提前 result";这里是工具从未 surface abort 的兄弟态(Exit Code: 0、Error: (none))。core 对同一竞态的处理相反(shell.ts:2966-2975 wasPromoteRefused:"report what actually happened ... rather than as a cancellation")。
见证(速率扫描,真实 executor,git commit --allow-empty,在 0-400ms 延迟取消 ×3):
8/27 rows settled executionStatus:"cancelled" with commitsDelta:1 (the commit landed)
row delay:50 -> {"executionStatus":"cancelled","commitLanded":true,
text:"...Output: [master 580cd93] msg-10 ... Exit Code: 0 ... Signal: (none)"}
建议修复: 终态分类依据执行自身的结果,而非仅凭日志标志——只有工具确实 surface 了 abort 才发布 cancelled,否则发布真实的 success/error。(或在源头修:shell.ts 的 aborted = abortSignal.aborted && !exited,复用既有的 exited/recordedExit 标志。)
修复仍须对"不带 error 的礼貌提前 result"发布 cancelled(§6),因此不能只看 result.error。
新增 worker 用例:取消一个工具在 abort 后仍完成的调用(一个小 write_file,或已退出的命令),断言 executionStatus:'success' 且副作用存在;当前仅凭标志的映射会报 cancelled 并变红。
— qwen3.8-max via Qwen Code /review (v0.24.6)
|
|
||
| Status: contract and Java tool transport implemented; worker handlers and | ||
| Broker transport wiring remain follow-up work | ||
| Status: contract, worker handlers, and Java tool transport implemented; |
There was a problem hiding this comment.
[Suggestion] R1-4: Mounting the tool routes and flipping this status line to "worker handlers ... implemented" falsifies three sibling design docs this PR does not touch — in both EN and zh-CN, which AGENTS.md requires to stay synchronized.
A maintainer or Broker-side wirer reading those docs plans work that has already landed, or treats a successful execute (200 settled) as a contract violation. Stale at this commit: (a) 2026-09-23-managed-runtime-process-adoption.md:17 (+zh-CN:17) "The merged worker still exposes only attestation, so execute against that process is a non-retryable 404"; (b) 2026-09-22-managed-runtime-attestation-contract.md:55/92/109/127 (+zh-CN :5/47/77/81/98/116) "the raw gate rejects those routes until their real handlers land" / "the only admitted operation is the exact attestation route" / "the attestation-only process"; (c) managed-runtime-broker-service-core.md:117 (+zh-CN:116) "The Java HTTP tool transport is implemented, but its worker routes and service adapter remain follow-up work" — only the "worker routes" half is now false ("service adapter" is still open).
Witness: not run — documentation claim; verified by reading the cited files at HEAD be47e552 and confirming via git diff --name-only merge-base..HEAD that this PR changes only 9 files, none of them these three docs.
Suggested fix: in the same change, update the three sibling docs (EN and zh-CN together) to point at §3.1/§6 (four routes mounted and gate-admitted); for broker-service-core, rewrite to name only what is still open (the service adapter), not delete the sentence.
中文说明
挂载工具路由并把本状态行翻成 "worker handlers ... implemented",会使本 PR 未触及的三份兄弟设计文档失真——中英两版皆然,而 AGENTS.md 要求两版保持同步。
读到这些文档的维护者或 Broker 侧接线者会去规划已经落地的工作,或把一次成功的 execute(200 settled)当成契约违背。在此 commit 已失真的有:(a) 2026-09-23-managed-runtime-process-adoption.md:17(+zh-CN:17)"已经合入的 worker 仍然只暴露 attestation,所以对这个进程执行工具会得到不可重试的 404";(b) 2026-09-22-managed-runtime-attestation-contract.md:55/92/109/127(+zh-CN :5/47/77/81/98/116)"在真实 handler 落地之前 raw gate 会拒绝这些路由"/"唯一放行的操作是精确的 attestation route"/"attestation-only process";(c) managed-runtime-broker-service-core.md:117(+zh-CN:116)"Java HTTP 工具 transport 已实现,但 worker 路由与服务适配层仍待后续完成"——现在只有"worker 路由"这半边为假("服务适配层"仍未完成)。
见证: not run——文档类主张;通过在该 HEAD be47e552 读取被引文件、并用 git diff --name-only merge-base..HEAD 确认本 PR 只改 9 个文件(都不含这三份文档)核实。
建议修复: 在同一笔变更中更新这三份兄弟文档(中英同步),指向 §3.1/§6(四条路由已挂载并被 gate 放行);broker-service-core 那句改写为只列仍未完成的部分(服务适配层),而非删除。
— qwen3.8-max via Qwen Code /review (v0.24.6)
|
|
||
| /** The reference identifies a different call than the recorded invocation. */ | ||
| export class ManagedToolConflictError extends Error { | ||
| readonly code = 'managed_runtime_identity_conflict'; |
There was a problem hiding this comment.
[Suggestion] R1-34: The code field added to ManagedToolConflictError has no read site — both 409 responses re-type the literal — so the class that reads as the single source of truth for a contract-declared "shared stable code" defines nothing.
Grep over packages/cli/src/serve: the symbol appears at the declaration, the three throws, and routes.ts (import + instanceof); nothing reads .code. The 409 bodies hardcode code: 'managed_runtime_identity_conflict' at routes.ts:133 and :152, and handleManagedRuntimeJsonError only inspects error.type. Renaming the code in the class changes nothing on the wire, breaks no test (worker.test.ts:168-170 asserts the fixture literal), and leaves the class asserting a code the route no longer sends — against doc:97 "JSON errors retain the shared stable codes". The string is hand-synced across three sites. (AGENTS.md Code Review: "For every added field ... grep its read sites ... a foo?: boolean that is declared and read but never set ... is a dead switch".)
Witness: mutation deleting the field (export class ManagedToolConflictError extends Error {}) → three serve suites 135 passed (135), identical to baseline; .code read sites → 0.
Suggested fix: either delete the field (the route literals are the only truth today), or emit it — res.status(409).json({ code: error.code, error: error.message }) at routes.ts:150-154 and drop the duplicate literal at :133 in favour of the same constant.
The wire string must stay exactly managed_runtime_identity_conflict either way (routes.ts:133/:152 send it today; doc:97 requires stable codes; the Java consumer parses the same closed error object).
中文说明
ManagedToolConflictError 上新增的 code 字段没有任何读取点——两个 409 响应各自重新硬编码该字面量——因此这个看起来像"契约声明的稳定共享码"唯一真源的类其实什么都没定义。
在 packages/cli/src/serve 全量 grep:该符号只出现在声明、三处 throw、以及 routes.ts(import + instanceof);没有任何一处读 .code。409 响应体在 routes.ts:133 与 :152 硬编码 code: 'managed_runtime_identity_conflict',而 handleManagedRuntimeJsonError 只检查 error.type。改类里的 code 既不改变线上、也不红任何测试(worker.test.ts:168-170 断言的是 fixture 字面量),反而让类开始声明一个路由不再发送的 code——与 doc:97 "JSON errors retain the shared stable codes" 相悖。同一字符串要在三处手工同步。(AGENTS.md Code Review:"为每个新增字段 grep 其读取点……声明并读取却从不被赋值的 foo?: boolean 是死开关"。)
见证: 删除该字段的变异(export class ManagedToolConflictError extends Error {})→ 三个 serve 套件 135 passed (135),与基线一致;.code 读取点 → 0。
建议修复: 要么删掉字段(今天路由字面量才是唯一真值),要么真正发出它——在 routes.ts:150-154 用 res.status(409).json({ code: error.code, error: error.message }),并把 :133 的重复字面量改为同一常量。
无论哪种方式,线上字符串都必须保持为 managed_runtime_identity_conflict(今天由 routes.ts:133/:152 发送;doc:97 要求码稳定;Java 消费方解析同一个封闭 error 对象)。
— qwen3.8-max via Qwen Code /review (v0.24.6)
| } | ||
| } | ||
|
|
||
| static forWorkspace(workspaceCwd: string, runtimeInstanceId: string) { |
There was a problem hiding this comment.
[Suggestion] R1-26: The per-generation Config registers process-global per-session model state in its constructor and close() never calls Config.shutdown(), so the registry entries are never released — the executor does not even retain the Config.
Config's constructor calls publishModelEnv() → registerSessionModel(sessionId, ...) into module-level maps (config.ts:3522/5962-5972); the only release is unregisterSessionModel inside shutdownResourcesOnce via Config.shutdown() (config.ts:7112/6994), which ManagedToolExecutor.close() never calls (it only aborts; the class keeps no Config reference). sessionIdContext.ts:110-118 warns "the map would otherwise grow one entry per session for the life of a daemon process". In today's dedicated-process shape (cli.ts:555 routes one process per generation; the provisioner destroy()s it) process exit reclaims them — hence Suggestion, not Critical; but an in-process host (the §5 harness-wiring follow-up, and the shape the worker tests already exercise) leaks two permanent entries per provisioned-and-retired generation.
Witness: PROBE26 after 5 closed executors -> still-registered after close(): 5/5 (each retains model + modelIdentity; projectDir undefined since the executor never initialize()s); a real worker generation stays registered after worker.close().
Suggested fix: store the Config on the executor (a private field assigned in the constructor) and await config.shutdown({ shutdownTelemetry: false }) in close() after the abort loop.
A plain shutdown() tears down the process-wide telemetry SDK (config.ts:7039-7040) that an in-process host shares with the primary runtime, so the call must pass shutdownTelemetry: false.
Add a worker case asserting getSessionModel(runtimeInstanceId) is defined after boot and undefined after worker.close(); removing the shutdown() call leaves it defined and the test goes red.
中文说明
每代 Config 在构造函数里注册进程级的按会话 model 状态,而 close() 从不调用 Config.shutdown(),因此注册表条目永不释放——executor 甚至没有保留这个 Config 引用。
Config 构造函数调用 publishModelEnv() → registerSessionModel(sessionId, ...) 写入模块级 map(config.ts:3522/5962-5972);唯一释放点是 shutdownResourcesOnce 里的 unregisterSessionModel,只能经 Config.shutdown()(config.ts:7112/6994)到达,而 ManagedToolExecutor.close() 从不调用它(只 abort;类里没有 Config 字段)。sessionIdContext.ts:110-118 警告"否则该 map 会在 daemon 进程存活期内每会话增长一条"。在今天"一代一进程"的形态下(cli.ts:555 每代一个进程;provisioner destroy() 掉它),进程退出即回收——故为 Suggestion 而非 Critical;但进程内宿主(§5 harness 接线 follow-up,也正是 worker 测试已经在跑的形态)每 provision+退休一代就泄漏两条永久条目。
见证: PROBE26 after 5 closed executors -> still-registered after close(): 5/5(各保留 model + modelIdentity;projectDir 为 undefined,因 executor 从不 initialize());真实 worker 代数经 worker.close() 后条目仍在。
建议修复: 把 Config 存到 executor 上(构造函数里赋给一个私有字段),并在 close() 的 abort 循环之后 await config.shutdown({ shutdownTelemetry: false })。
裸 shutdown() 会拆掉进程级 telemetry SDK(config.ts:7039-7040),而进程内宿主与主 runtime 共享它,所以调用必须传 shutdownTelemetry: false。
新增 worker 用例:断言 getSessionModel(runtimeInstanceId) 在 boot 后有定义、在 worker.close() 后为 undefined;去掉 shutdown() 调用后它仍有定义,用例变红。
— qwen3.8-max via Qwen Code /review (v0.24.6)
| debugMode: false, | ||
| usageStatisticsEnabled: false, | ||
| approvalMode: ApprovalMode.YOLO, | ||
| fileCheckpointingEnabled: false, |
There was a problem hiding this comment.
[Suggestion] R1-14: forWorkspace's Config never disables artifact recording and artifactEnabled defaults true, so a write_file of an artifact-kind file appends a sentence claiming it "was automatically recorded as a workspace artifact" — in a slice that has no artifact track.
isRecordArtifactEnabled() (config.ts:9073-9078) falls through to this.artifactEnabled = params.artifactEnabled ?? true (config.ts:3310); forWorkspace passes neither that nor sdkMode. A managed-runtime write_file of report.html/.csv/.ipynb/.png/.svg/.pdf/office doc inside the workspace hits write-file.ts:619-631 and pushes formatRecordArtifactReminder(...) into llmSuccessMessageParts → llmContent → toPayload's string arm → responseParts on the wire. The model is told a workspace artifact was recorded with a resolvable workspacePath, while §3.1 routes larger outputs through the artifact delivery track and §6 lists that track as still follow-up. The reminder also spends bytes of the 1 MiB cap. Core's own headless worker defends against exactly this: config.isRecordArtifactEnabled = () => false (execution-worker.ts:44-45).
Witness (real worker):
INTACT write_file report.html -> responseParts text "... This file was automatically recorded as a workspace artifact with workspacePath \"report.html\". ..."
FLIP artifactEnabled:false -> "...wrote to new file: .../report.html." (reminder gone)
CONSTRAINT artifactEnabled:false + QWEN_CODE_ENABLE_ARTIFACT=1 -> reminder RETURNS
isRecordArtifactEnabled=()=>false + same env flag -> reminder stays gone
Suggested fix: in forWorkspace, build the Config into a local and set config.isRecordArtifactEnabled = () => false before passing it to new ManagedToolExecutor(config), mirroring execution-worker.ts:45.
config.ts:9076 evaluates if (process.env['QWEN_CODE_ENABLE_ARTIFACT'] === '1') return true; before return this.artifactEnabled, so artifactEnabled: false alone does not hold under that env flag — the method override is the shape that does.
Add a worker write_file report.html case asserting the settled result text does NOT contain "recorded as a workspace artifact"; removing the override makes it red.
中文说明
forWorkspace 的 Config 从不关闭 artifact 记录,而 artifactEnabled 默认为 true,因此对 artifact 类文件执行 write_file 会追加一句声称该文件"已自动记录为工作区 artifact"——可这一切片根本没有 artifact 通道。
isRecordArtifactEnabled()(config.ts:9073-9078)最终落到 this.artifactEnabled = params.artifactEnabled ?? true(config.ts:3310);forWorkspace 两者都没传。worker 内对工作区里的 report.html/.csv/.ipynb/.png/.svg/.pdf/office 文档执行 write_file,会命中 write-file.ts:619-631,把 formatRecordArtifactReminder(...) 推进 llmSuccessMessageParts → llmContent → toPayload 的字符串分支 → 线上的 responseParts。模型被告知有一个工作区 artifact 被记录、且带可解析的 workspacePath,而 §3.1 把更大的输出交给 artifact 交付通道、§6 把该通道列为后续工作。这句提示还会占用 1 MiB 上限的字节。core 自己的 headless worker 正是这样防御的:config.isRecordArtifactEnabled = () => false(execution-worker.ts:44-45)。
见证(真实 worker):
INTACT write_file report.html -> responseParts text "... This file was automatically recorded as a workspace artifact with workspacePath \"report.html\". ..."
FLIP artifactEnabled:false -> "...wrote to new file: .../report.html." (reminder gone)
CONSTRAINT artifactEnabled:false + QWEN_CODE_ENABLE_ARTIFACT=1 -> reminder RETURNS
isRecordArtifactEnabled=()=>false + same env flag -> reminder stays gone
建议修复: 在 forWorkspace 里把 Config 构造到一个局部变量,在传给 new ManagedToolExecutor(config) 之前设置 config.isRecordArtifactEnabled = () => false,与 execution-worker.ts:45 一致。
config.ts:9076 先判 if (process.env['QWEN_CODE_ENABLE_ARTIFACT'] === '1') return true; 再 return this.artifactEnabled,所以在该 env flag 下单靠 artifactEnabled: false 不成立——方法覆写才是可靠的形态。
新增 worker 的 write_file report.html 用例,断言结算结果文本不含 "recorded as a workspace artifact";去掉该覆写后用例变红。
— qwen3.8-max via Qwen Code /review (v0.24.6)
| const content = result.llmContent; | ||
| const responseParts = | ||
| typeof content === 'string' | ||
| ? [{ type: 'text', text: content }] |
There was a problem hiding this comment.
[Suggestion] R1-1: toPayload publishes two incompatible element shapes in the one responseParts wire field, and its : [] fallback silently drops a bare-Part result while still reporting success.
(1) Shape fork: the string arm emits [{ type: 'text', text: content }], which is not an SDK Part (Part has no type member), while the array arm passes raw Part[] through. Doc §3.3:91-95 declares the element shape "deliberately deferred to the worker handler and Broker wiring slices, which must derive it from the actual tool-result path ... and add shared conformance coverage before serving results", and calls the fixtures' text parts "illustrative, not a new part format" — yet this slice is the worker handler, it serves results, and it pins nothing (schema responseParts is {type:array} with no items; the sole real-result assertion is toContain). The §5 Broker projection and the §6 "image input support" follow-up must then read one field whose elements have two shapes. (2) Latent drop: read_file of media returns a bare Part ({inlineData}), a legal PartListUnion member; the : [] arm discards it and reports success with empty responseParts, also defeating the 1 MiB guard. This is not reachable today (the synthetic managed-runtime-worker model is text-only with no vision bridge, so media returns an unsupported-modality string) but is unlocked by that same image-input follow-up.
Witness:
read_file media today: typeof=string (unsupported-modality text) -> ': []' unreachable
with preserveUnsupportedImage forced true: typeof=object isArray=false keys=inlineData (the shape ': []' drops)
wire body (execute/status/cancel alike): responseParts:[{type:'text',text:'file contents'}]
SDK Part (genai.d.ts): no 'type' member; git diff merge-base..HEAD: 9 files, schema+fixtures not among them
Suggested fix: treat a non-string non-array llmContent as the single Part it is (: content ? [content] : [], or normalize via core's normalizeParts), and emit one element shape for the field — either the SDK Part shape ({ text }) for strings or map arrays through the same encoder — then pin it: add an items constraint to responseParts in the shared schema and record the chosen shape in §3.3 (EN+zh).
managed-runtime-tool-v2.fixtures.json:71/212 declare responseParts:[{type:'text',text:'file contents'}] and the Java conformance suite consumes the same files, so changing the emitted shape without changing the fixtures in the same batch forks the two languages' contracts; HttpRuntimeTransport.java:327 requires responseParts to be a List, and real media bytes must still take the documented terminal-oversize path under the 1 MiB cap.
Tighten worker.test.ts:199 from toContain to a shape assertion on the live envelope's responseParts element; it goes red if the {type:'text'} wrapper is reintroduced or the bare-Part arm returns [].
中文说明
toPayload 在同一个 responseParts 线上字段里发布两种不兼容的元素形状,且它的 : [] 兜底会静默丢弃裸 Part 结果却仍报 success。
(1) 形状分叉: 字符串分支发出 [{ type: 'text', text: content }],这不是 SDK Part(Part 没有 type 成员),而数组分支原样透传 Part[]。文档 §3.3:91-95 声明元素形状"被刻意推迟到 worker handler 与 Broker 接线切片,它们必须从真实的 tool-result 路径推导……并在开始提供结果之前补齐共享 conformance 覆盖",并称 fixture 里的 text part 是"示例,不是一种新的 part 格式"——可这一切片正是 worker handler、已经在提供结果、且什么都没钉住(schema 的 responseParts 只是 {type:array} 无 items;唯一对真实结果的断言是 toContain)。于是 §5 的 Broker 投影与 §6 的"图片输入支持"后续必须读一个元素有两种形状的字段。(2) 潜在丢弃: read_file 读媒体会返回裸 Part({inlineData}),是合法的 PartListUnion 成员;: [] 分支把它丢掉并报 success+空 responseParts,还使 1 MiB 守卫失效。今天不可达(合成模型 managed-runtime-worker 是纯文本、无 vision bridge,媒体返回 unsupported-modality 字符串),但会被同一个图片输入后续解锁。
见证:
read_file media today: typeof=string (unsupported-modality text) -> ': []' unreachable
with preserveUnsupportedImage forced true: typeof=object isArray=false keys=inlineData (the shape ': []' drops)
wire body (execute/status/cancel alike): responseParts:[{type:'text',text:'file contents'}]
SDK Part (genai.d.ts): no 'type' member; git diff merge-base..HEAD: 9 files, schema+fixtures not among them
建议修复: 把非 string 非 array 的 llmContent 当作它本来的单个 Part 处理(: content ? [content] : [],或用 core 的 normalizeParts 归一),并为该字段发出统一的元素形状——字符串也用 SDK Part 形状({ text }),或把数组也过同一编码器——然后钉住它:在共享 schema 给 responseParts 加 items 约束,并在 §3.3(中英)记录所选形状。
managed-runtime-tool-v2.fixtures.json:71/212 声明 responseParts:[{type:'text',text:'file contents'}],Java conformance 套件消费同一批文件,所以改变发出的元素形状却不在同批改 fixture 会让两种语言的契约分叉;HttpRuntimeTransport.java:327 要求 responseParts 是 List,且真实媒体字节仍须在 1 MiB 上限下走文档化的终态超限路径。
把 worker.test.ts:199 从 toContain 收紧为对真实信封 responseParts 元素的形状断言;一旦重新引入 {type:'text'} 包装或裸 Part 分支返回 [],它会变红。
— qwen3.8-max via Qwen Code /review (v0.24.6)
|
|
||
| describe('replays the shared negative fixtures against the real handlers', () => { | ||
| for (const suite of suites) { | ||
| for (const fixture of suite.cases) { |
There was a problem hiding this comment.
[Suggestion] R1-40: The 725-line route suite covers the wire protocol's happy and negative paths well, but 17 internal behaviours of the new executor/routes/worker are not pinned by any test — each was confirmed by a mutation that leaves all 135 serve-suite tests green. Filed as one systemic coverage gap; regressions in any of these ship silently.
Executor branches: (R1-5) close()-aborts-in-flight is the sole shutdown child-kill — reverting it leaves a sleep alive ≥3s; (R1-6) write_file and edit (2 of 4 admitted tools) are never executed — removing new EditTool(config) keeps 40/40 green, so a legal edit would 409 undetected; (R1-7) the oversize "preserve cancelled" branch (ternary→'error' mutation green); (R1-8) sameInvocation toolName/inputJson criteria — a run_shell_command call joins a read_file invocation and returns the OTHER tool's result at HTTP 200; (R1-27) the "tools receive a copy" witness cannot fail (byte-identical requests, inputJson fixed before the tool runs); (R1-32) toPayload's if (toolError) branch — no admitted tool ever fails in the suite, so deleting it publishes a failed exit 3 command as executionStatus:'success'; (R1-33) the status/cancel cross-session sameReference guard — deleting it lets session-b read session-a's result and abort A's run; (R1-36) cancel idempotency on cancel_requested (the precise mutant survives 135 green); (R1-20) the foreground-admission table asserts only {settled,success}, never output, so a background-detached command passes all five; (R1-21) the in-flight-cancel test asserts wire state from the journal flag, not child termination.
Route constraints: (R1-9) status/cancel 16 KiB body cap (mutation→256 KiB turns 413 into 200); (R1-10) status afterSequence rejection (mutation turns 400 into 200); (R1-19) cancel body validation has zero negative coverage — a reference-less cancel reaches executor.cancel(null) → TypeError → text/html 500 with a stack trace on the wire; (R1-29) execute toolName/input shape validation — dropping Array.isArray(input) lets input:[] journal then settle 200 error, violating §3.2 "400 before creating a journal entry" and occupying the callId; (R1-16) execute/cancel settled envelopes use toMatchObject, so the "no lastSequence" constraint (a Java non-retryable protocol error) is unpinned; (R1-28) the unadmitted-tool case asserts only status 409, not code/error/journal.
Worker invariant: (R1-13) the 5s requestTimeout has no incomplete-upload→408 test — requestTimeout=0 leaves a stalled authenticated upload open at 45s vs intact 408 at 30s.
Witness: each gap confirmed by an executed mutation in a scratch tree against the intact baseline (managed-runtime-tool-worker.test.ts 40 passed; three serve suites 135 passed). Representative: R1-32 delete if (toolError) → 135 green, probe shows exit 3 published as executionStatus:"success" with no error object; R1-19 relax cancel body validation → reference-less cancel returns text/html 500 <pre>TypeError: Cannot read properties of null (reading 'callId'); R1-13 requestTimeout=0 → stalled upload STILL OPEN at 45009ms vs intact 408 at 30064ms.
Suggested fix: add mutation-pinning cases for each behaviour (drive write_file/edit; assert close()/cancel actually terminate the child via a sentinel or process.kill(pid,0); vary toolName/input under a fixed callId+digest expecting 409; assert settled execute/cancel envelopes with toEqual and no lastSequence; add HTTP negatives for status/cancel body size, afterSequence, cancel reference, and execute toolName/input shape; assert the foreground table's command output; add a tool-error case (exit 3, near-zero output) asserting executionStatus:'error' + error.type; add an incomplete-upload→408 case).
Each new case must go red when its target branch/constraint is removed (the mutations above) — today every one of those mutations leaves the suite green, which is the gap. The requestTimeout case must probe an incomplete upload, not a long execution (a 40s foreground execute succeeds at 40055ms under requestTimeout=5_000); the cancel/execute envelopes must stay without lastSequence (HttpRuntimeTransport.java:299-305); and the afterSequence/cancel-body negatives must be inline cases, not shared fixtures (the schema rejects route-invalid shapes in fixtures by design).
中文说明
725 行的路由套件把线协议的成功与负面路径覆盖得不错,但新 executor/routes/worker 的 17 个内部行为没有任何测试钉住——每一个都经变异确认:变异后 135 个 serve 套件用例仍全绿。作为一条系统性覆盖缺口合并提交;其中任何一个回归都会静默上线。
Executor 分支:(R1-5)close() 中止在途执行是关停时唯一的子进程杀手机制——还原它会让 sleep 存活 ≥3s;(R1-6)write_file 与 edit(四个准入工具中的两个)从未被执行——删掉 new EditTool(config) 后 40/40 仍绿,合法的 edit 会无声 409;(R1-7)超限"保留 cancelled"分支(ternary→'error' 变异绿);(R1-8)sameInvocation 的 toolName/inputJson 判据——一个 run_shell_command 调用会并入 read_file 调用并在 HTTP 200 返回另一个工具的结果;(R1-27)"工具收到副本"的见证用例不可能失败(两次请求字节相同、inputJson 在工具运行前已固化);(R1-32)toPayload 的 if (toolError) 分支——套件里没有任何准入工具会失败,删掉它会把 exit 3 的失败命令发布为 executionStatus:'success';(R1-33)status/cancel 的跨会话 sameReference 守卫——删掉它会让 session-b 读到 session-a 的结果并中止 A 的运行;(R1-36)cancel_requested 上的 cancel 幂等(精确变异体在 135 绿下存活);(R1-20)前台准入表只断言 {settled,success}、从不断言输出,被分离到后台的命令五个用例全过;(R1-21)在途取消用例断言的是来自日志标志的线状态,而非子进程终止。
路由约束:(R1-9)status/cancel 的 16 KiB 体上限(变异→256 KiB 把 413 变成 200);(R1-10)status 的 afterSequence 拒绝(变异把 400 变成 200);(R1-19)cancel 体校验零负面覆盖——缺 reference 的 cancel 会进入 executor.cancel(null) → TypeError → 线上 text/html 500 带栈;(R1-29)execute 的 toolName/input 形状校验——删掉 Array.isArray(input) 会让 input:[] 建日志再结算 200 error,违反 §3.2"建日志前 400"并占用 callId;(R1-16)execute/cancel 结算信封用 toMatchObject,"不得带 lastSequence"(Java 不可重试协议错误)未被钉住;(R1-28)未准入工具用例只断言状态 409,不断言 code/error/日志。
Worker 不变量:(R1-13)5s requestTimeout 没有 incomplete-upload→408 测试——requestTimeout=0 会让一个停滞的已鉴权上传在 45s 仍开放,而完整代码 30s 返回 408。
见证: 每个缺口都在 scratch tree 里以执行的变异对照完整基线确认(managed-runtime-tool-worker.test.ts 40 passed;三个 serve 套件 135 passed)。代表性:R1-32 删 if (toolError) → 135 绿,探针显示 exit 3 被发布为 executionStatus:"success" 且无 error 对象;R1-19 放宽 cancel 体校验 → 缺 reference 的 cancel 返回 text/html 500 <pre>TypeError: Cannot read properties of null (reading 'callId');R1-13 requestTimeout=0 → 停滞上传在 45009ms 仍开放,完整代码 30064ms 返回 408。
建议修复: 为每个行为补上能钉住变异的用例(驱动 write_file/edit;用 sentinel 或 process.kill(pid,0) 断言 close()/cancel 确实终止子进程;在固定 callId+digest 下变动 toolName/input 断言 409;用 toEqual 断言 execute/cancel 结算信封且不含 lastSequence;为 status/cancel 体大小、afterSequence、cancel reference、execute toolName/input 形状补 HTTP 负例;断言前台表的命令输出;补一个工具失败用例(exit 3、近乎零输出)断言 executionStatus:'error' + error.type;补一个 incomplete-upload→408 用例)。
每个新用例在其目标分支/约束被移除时必须变红(上面的变异)——今天这些变异都让套件保持绿,这正是缺口所在。requestTimeout 用例必须探"未完整上传"而非"执行时间长"(requestTimeout=5_000 下 40s 前台 execute 在 40055ms 成功);cancel/execute 信封必须保持不带 lastSequence(HttpRuntimeTransport.java:299-305);afterSequence/cancel-body 负例必须是内联用例而非共享 fixture(schema 有意拒绝 fixture 里的路由非法形状)。
— qwen3.8-max via Qwen Code /review (v0.24.6)
| }, | ||
| ); | ||
|
|
||
| it.each([1, 'yes', null, {}, [true]])( |
There was a problem hiding this comment.
[Suggestion] R1-12: This it.each table mixes scalars and one array ([1, 'yes', null, {}, [true]]), so vitest does not spread args and %j renders items[0] — the [true] case's title becomes is_background=true, identical to line 509's case that asserts the opposite result (409 vs 200+error).
vitest spreads args only when every case is an array (@vitest/runner arrayOnlyCases = cases.every(Array.isArray)). This table is not array-only, so the handler receives the unspread value ([true] stays an array) while the title gets items[0] === true (boolean). On a regression in the schema-rejection path, the failing test reads "settles invalid is_background=true without starting a command", pointing the triager at the background-admission branch (executor.ts:141-152) when the path actually under test is SchemaValidator rejecting an array. Line 558's %s likewise renders boolean false and string 'false' as the identical title is_background=false, so two cases in one describe are indistinguishable in CI output.
Witness (probe reproducing the three it.each shapes):
A(509) title="rejects background shell execution with is_background=true ..." received=true (boolean)
B(584) title="settles invalid is_background=true without starting a command" received=[true] (array)
C(558) title="executes foreground shell commands with is_background=false" received=false AND received="false" (identical titles)
fix (array-only) -> title="settles invalid is_background=[true] ..." (collision gone)
Suggested fix: make the tables array-only so vitest spreads args and the title matches the handler value — it.each([[1], ['yes'], [null], [{}], [[true]]]) and it.each([[false], [undefined], ['false'], ['FALSE'], ['FaLsE']]) with %j.
executor.ts:143 reads params['is_background'] === true (strict boolean), so the value reaching the handler must stay the array [true], not boolean true, or the case would take the 409 background-admission branch instead of the asserted 200 settled-error path.
中文说明
这个 it.each 表混用了标量与一个数组([1, 'yes', null, {}, [true]]),所以 vitest 不展开参数、%j 渲染 items[0]——[true] 这一例的标题变成 is_background=true,与第 509 行断言相反结果(409 vs 200+error)的用例标题完全相同。
vitest 只在所有用例都是数组时才展开参数(@vitest/runner 的 arrayOnlyCases = cases.every(Array.isArray))。本表不是全数组,于是 handler 收到未展开的值([true] 仍是数组),而标题拿到 items[0] === true(布尔)。当 schema 拒绝路径回归时,失败的用例显示 "settles invalid is_background=true without starting a command",把排查者引向后台准入分支(executor.ts:141-152),而真正被测的是 SchemaValidator 拒绝数组类型的路径。第 558 行的 %s 同样让布尔 false 与字符串 'false' 渲染成完全相同的标题 is_background=false,于是同一 describe 内有两个在 CI 输出里无法区分的用例。
见证(探针复现三处 it.each 形状):
A(509) title="rejects background shell execution with is_background=true ..." received=true (boolean)
B(584) title="settles invalid is_background=true without starting a command" received=[true] (array)
C(558) title="executes foreground shell commands with is_background=false" received=false AND received="false" (identical titles)
fix (array-only) -> title="settles invalid is_background=[true] ..." (collision gone)
建议修复: 把用例表改成全数组形式,让 vitest 走展开分支、标题与实参一致——it.each([[1], ['yes'], [null], [{}], [[true]]]) 与 it.each([[false], [undefined], ['false'], ['FALSE'], ['FaLsE']]),并用 %j。
executor.ts:143 读 params['is_background'] === true(严格布尔),所以到达 handler 的值必须仍是数组 [true] 而非布尔 true,否则该用例会改走 409 后台准入分支,而不是它断言的 200 结算为 error 的路径。
— qwen3.8-max via Qwen Code /review (v0.24.6)






What this PR does
Adds the Managed Runtime worker's v2 execute, status, and cancel operations alongside attestation. The worker admits read, write, edit, and foreground shell calls in the attested workspace, shares the reviewed protocol's authentication and lease fencing, and keeps a process-local invocation journal. Exact retries join or replay the original result; conflicting identities return 409, and unseen references remain unknown.
The branch incorporates the reviewed contract and broker updates from main. Tool routes are admitted through the exact HTTP gate, background shell calls are rejected after parameter normalization and before execution, and the request-receipt timeout remains enabled. Inputs that cannot be encoded for identity comparison return 400 before journaling; accepted input identities are encoded once for stable retries. The worker preserves original request inputs for retries, disables conversation-dependent file-read caching, and records a bounded terminal error when the serialized output would exceed 1 MiB.
Why it's needed
The shared contract and attestation transport existed, but the worker did not serve tool operations. This provides the serving half of tool execution. Main now includes the Java tool transport from #12637; Broker transport wiring and Harness-side wiring remain separate follow-ups.
Reviewer Test Plan
How to verify
Start a worker with an attested temporary workspace and issue authenticated tool requests. Confirm real file reads, writes, edits, foreground execution, unknown lookups, and cancellation. Concurrent and settled retries must cause exactly one side effect, including when the tool normalizes null parameters or paths. Separate sessions must each receive full file contents.
Confirm boolean true and case-insensitive string true background calls return 409 without starting a process or recording an invocation, while boolean/string false remains foreground. Parameter validation or copying failures after protocol admission must return the same settled error on retry; inputs too deeply nested for JSON encoding must return the same 400 on first request and retry, with unknown status/cancel. Wrong methods, undeclared paths, trailing slashes, and query strings must remain 404; the shared negative fixtures must preserve their expected status and error code. An incomplete authenticated upload must receive 408, while a complete request may finish a 40-second foreground command successfully. JSON-expanded SVG output must leave the same bounded terminal error available through execute, status, cancel, and replay.
Evidence (Before & After)
Before: merged tool routes returned 404; background cancellation left the process alive; incomplete uploads stayed open past 35 seconds; a different session received an unchanged-file placeholder; identical normalized-input retries returned 409; an SVG produced responses above 1.2 MB. After: the focused regression tests and production-worker probes verify the corrected outcomes. The verification comment records the final test counts and measured results.
Tested on
Environment
Node 22.22.2, Java 21, and a freshly built production CLI bundle.
Risk & Scope
Design: English / 简体中文.
Linked Issues
Related to #12380. Builds on the merged contract from #12630 and incorporates the Java transport from #12637.
中文说明
这个 PR 做什么
在 attestation 之外,为 Managed Runtime worker 增加 v2 execute、status 与 cancel 操作。worker 在已证明的工作区中准入读取、写入、编辑与前台 shell 调用,沿用已评审协议的鉴权和租约围栏,并维护进程内调用日志。完全相同的重试会并入或回放原始结果;身份冲突返回 409,未见过的 reference 仍返回 unknown。
分支已合入 main 上评审后的契约与 broker 更新。工具路由通过精确 HTTP gate 放行,后台 shell 经参数归一化后在执行之前被拒绝,请求接收超时保持启用。无法编码以比较身份的输入在建立日志前返回 400;已准入输入的身份只编码一次,供稳定重试。worker 保留原始请求输入用于识别重试,禁用依赖对话的文件读取缓存,并在序列化输出超过 1 MiB 时记录有界的终态错误。
为什么需要
共享契约与 attestation transport 已存在,但 worker 尚未提供工具操作。这一变更提供工具执行的服务侧;main 已包含 #12637 的 Java 工具 transport;Broker transport 接入与 Harness 侧接线仍为独立后续工作。
评审测试计划
如何验证
使用已证明的临时工作区启动 worker,发送已鉴权的工具请求。确认真实文件读取、写入、编辑、前台执行、unknown 查询与取消。并发和已结算重试必须只产生一次副作用,包括工具会规范化 null 参数或路径的情况。不同会话必须各自获得完整文件内容。
确认布尔 true 和不区分大小写的字符串 true 后台调用返回 409,且不会启动进程或记录调用;布尔或字符串 false 仍以前台执行。通过协议准入后的参数校验或复制失败,首次与重试必须返回相同已结算错误;嵌套过深而无法 JSON 编码的输入,首次与重试均须返回同一 400,status/cancel 保持 unknown。错误方法、未声明路径、尾部斜杠与查询字符串仍须返回 404;共享负面 fixtures 保持预期状态和错误码。未完整上传的已鉴权请求须收到 408,而完整请求可以等待 40 秒前台命令成功完成。经 JSON 转义膨胀的 SVG 输出必须留下同一个有界终态错误,供 execute、status、cancel 与重试查询。
前后证据
修复前:合并后的工具路由返回 404;取消后台命令后进程仍存活;未完整上传的请求超过 35 秒仍开放;不同会话收到文件未变化占位提示;相同归一化输入重试返回 409;SVG 响应超过 1.2 MB。修复后:针对性回归测试与生产 worker 探针验证了正确结果。验证评论记录最终测试数量与实测结果。
测试平台
macOS 已验证;Windows 与 Linux 未在本地运行。
环境
Node 22.22.2、Java 21 与重新构建的生产 CLI bundle。
风险与范围
设计:English / 简体中文。
关联事项
关联 #12380,基于已合入的 #12630 契约,并合入 #12637 的 Java transport。