Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix(cli): address managed runtime review and merge main
  • Loading branch information
wenshao committed Sep 25, 2026
commit be47e55274987630192a0dc7e200986c23a8a06e
39 changes: 23 additions & 16 deletions docs/design/2026-09-24-managed-runtime-tool-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

[English](2026-09-24-managed-runtime-tool-contract.md) | [简体中文](2026-09-24-managed-runtime-tool-contract.zh-CN.md)

Status: contract and worker handlers implemented; the Java tool transport
remains a follow-up
Status: contract, worker handlers, and Java tool transport implemented;

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: 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)

Broker transport wiring remains follow-up work

Related: #12380 (Managed Agent staged delivery), the attestation contract in
[2026-09-22-managed-runtime-attestation-contract.md](2026-09-22-managed-runtime-attestation-contract.md),
Expand All @@ -29,10 +29,11 @@ design already demands:
In scope: route manifest declarations, the shared schema and conformance
fixtures, and the worker handlers with their raw HTTP gate admission.
TypeScript contract tests and the Java fixture consumer share the contract;
the worker tests exercise the mounted handlers.
the worker tests exercise the mounted handlers. The Java `HttpRuntimeTransport`
implements `execute`, `status`, and `cancel` against this contract.

Out of scope: the Java `HttpRuntimeTransport` implementation of
`execute`/`status`/`cancel`, Harness-side tool wiring, and a
Out of scope: wiring `HttpRuntimeTransport` into `RuntimeTransport`,
Harness-side tool wiring, and a
`not_started_proven` outcome, which needs the durable receipt store.

## 3. Design
Expand Down Expand Up @@ -69,6 +70,11 @@ Every request is a closed object:
The reference is the original call identity the harness assigned; the Runtime
never learns any Broker-side identifier.

Tool input must also be encodable by the Runtime's JSON encoder for identity
comparison. Unencodable input, including excessive nesting within the byte
limit, is rejected with 400 before creating a journal entry. The journal keeps
the encoded input so retries compare strings without re-encoding stored data.

### 3.3 Responses

Every success is a closed object carrying `protocolVersion` and a `state` of
Expand Down Expand Up @@ -155,16 +161,13 @@ Run `mvn test` and `mvn checkstyle:check` in
`packages/sdk-java/runtime-broker`. HTTP fixture replays verify the canonical
requests, success and unknown answers, malformed references and responses,
route-specific request limits, and tool results above 16 KiB through 1 MiB.
The worker still rejects the tool routes until handlers land.
The worker handlers described below serve these routes.

## 5. Follow-up work

- Replace the unused pre-contract `HttpRuntimeTransport.execute` stub and
implement the three operations as a `RuntimeTransport`. The stub sends a
session envelope, discards results, and caps responses at 16 KiB; it is not
connected to Broker dispatch. The follow-up must supply `toolName`/`input`,
use the reference-only identity envelope and the per-route limits here,
and adapt execute's settled envelope to the Broker's result shape.
- Complete the session verbs and wire `HttpRuntimeTransport` into
`RuntimeTransport`. Supply `toolName`/`input` separately from the stored
reference as described in §4.1, and cover real Broker dispatch end to end.
- The `UNKNOWN` execution reconciler shipped in #12655. Its transport must
validate the status wire envelope, then project it to `{state, result}`
(`result` only for `settled`). Strip `protocolVersion` and `lastSequence`;
Expand Down Expand Up @@ -194,8 +197,11 @@ Semantics mounted on the contract:
in-flight invocation or returns its settled result; the same `callId` with
a different digest or payload is a 409 identity conflict. An unadmitted
tool name is a 409 as well — it can never be valid for this generation.
`run_shell_command` with `is_background: true` is rejected before creating
a journal entry or starting a process; omitted or false remains foreground.
`run_shell_command` whose validated input normalizes to `is_background: true`
is rejected before creating a journal entry or starting a process. This
includes case-insensitive string `"true"`; omitted, false, or string `"false"`
remains foreground. After protocol admission, parameter validation or copying
failures settle as errors without execution.
Tools receive a copy of the input so parameter normalization cannot change
the original payload used to identify retries.
- `status` is read-only and answers `unknown` (200) for a reference the
Expand Down Expand Up @@ -224,8 +230,9 @@ behavioral cases execute a real `read_file` in a temporary workspace, answer
`unknown` for unseen references, join a concurrent duplicate execute, reject
a same-callId different-digest retry with 409, refuse an unadmitted tool, and
cancel an in-flight foreground shell command. Additional regression cases
reject background shell calls without recording them and admit both omitted
and explicit false `is_background` values.
reject background shell calls without recording them, including normalized
string booleans; admit omitted and boolean/string false `is_background`
values; and settle invalid values without starting a command.

Still follow-up: harness-side `RuntimeBackedTool` wiring, file-history
settlement, capability-digest verification against the admitted tool set,
Expand Down
16 changes: 9 additions & 7 deletions docs/design/2026-09-24-managed-runtime-tool-contract.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[English](2026-09-24-managed-runtime-tool-contract.md) | [简体中文](2026-09-24-managed-runtime-tool-contract.zh-CN.md)

状态:契约与 worker 处理器已实现;Java 工具 transport 仍为后续工作
状态:契约、worker 处理器与 Java 工具 transport 已实现;Broker transport 接入仍为后续工作

相关:#12380(Managed Agent 分阶段交付)、[2026-09-22-managed-runtime-attestation-contract.md](2026-09-22-managed-runtime-attestation-contract.md) 的 attestation 契约,以及 #12380 上本契约所答复的对账讨论。

Expand All @@ -16,9 +16,9 @@ owned Managed Runtime worker 在 attestation 之外增加三个工具操作—

## 2. 范围

范围内:路由清单声明、共享 schema 与 conformance fixtures,以及 worker 处理器和对应的 raw HTTP gate 放行。TypeScript 契约测试与 Java fixture 消费方共享契约;worker 测试覆盖已挂载的处理器。
范围内:路由清单声明、共享 schema 与 conformance fixtures,以及 worker 处理器和对应的 raw HTTP gate 放行。TypeScript 契约测试与 Java fixture 消费方共享契约;worker 测试覆盖已挂载的处理器。Java `HttpRuntimeTransport` 按本契约实现 `execute`、`status`、`cancel`。

范围外:Java `HttpRuntimeTransport` 的 `execute`/`status`/`cancel` 实现、Harness 侧工具接线,以及 `not_started_proven` 结果(需要持久回执存储)。
范围外:将 `HttpRuntimeTransport` 接为 `RuntimeTransport`、Harness 侧工具接线,以及 `not_started_proven` 结果(需要持久回执存储)。

## 3. 设计

Expand All @@ -40,6 +40,8 @@ worker 已挂载全部四个声明的处理器。`ownedManagedRuntimeRouteGate`

reference 是 harness 分配的原始调用身份;Runtime 不会得知任何 Broker 侧标识。

工具输入还必须能由 Runtime 的 JSON 编码器编码,以便比较调用身份。不可编码的输入(包括未超出字节上限但嵌套过深的输入)在创建日志条目前以 400 拒绝。日志保留编码后的输入,重试只比较字符串,不再重新编码已记录的数据。

### 3.3 响应

每个成功响应都是封闭对象,携带 `protocolVersion` 与 `state`(`prepared`、`executing`、`cancel_requested`、`settled`、`unknown` 之一):
Expand Down Expand Up @@ -70,11 +72,11 @@ reference 是 harness 分配的原始调用身份;Runtime 不会得知任何 B

超过对应路由上限的请求在发送前被拒绝:`execute` 为 256 KiB,`status` 与 `cancel` 为 16 KiB。响应上限为 1 MiB,且必须带 `no-store` 与 JSON 响应头。解析强制信封、result、error 为封闭对象,协议版本为 2,状态与执行结局属于契约枚举,错误字符串非空,且仅在 settled 时必须携带 result。`lastSequence` 为可选非负整数,仅允许出现在 `status`。`execute` 要求结算并返回 result map;`status` 与 `cancel` 返回校验后的线上 map。共享错误码保持不变,错误消息标明操作与适用上限。服务端失败可重试,其他 HTTP 失败为终态。

在 `packages/sdk-java/runtime-broker` 运行 `mvn test` 和 `mvn checkstyle:check`。HTTP fixture 回放验证 canonical 请求、成功与 unknown 应答、畸形 reference 和响应、逐路由请求上限,以及超过 16 KiB 直至 1 MiB 的工具结果。worker 在处理器落地前仍拒绝工具路由。
在 `packages/sdk-java/runtime-broker` 运行 `mvn test` 和 `mvn checkstyle:check`。HTTP fixture 回放验证 canonical 请求、成功与 unknown 应答、畸形 reference 和响应、逐路由请求上限,以及超过 16 KiB 直至 1 MiB 的工具结果。下文描述的 worker 处理器已提供这些路由。

## 5. 后续工作

- 替换尚未使用、早于本契约的 `HttpRuntimeTransport.execute` 占位实现,并将三个操作实现为 `RuntimeTransport`。该占位实现发送会话信封、丢弃结果、限制响应为 16 KiB,且未接入 Broker dispatch。后续必须提供 `toolName`/`input`、采用本契约仅以 reference 标识调用的信封与逐路由上限,并把 execute 的 settled 信封适配为 Broker 的结果形态。
- 完成会话操作并将 `HttpRuntimeTransport` 接为 `RuntimeTransport`。按 §4.1 所述从已保存的 reference 之外单独提供 `toolName`/`input`,并端到端覆盖真实 Broker 分发。
- `UNKNOWN` 执行对账器已在 #12655 落地。其 transport 必须先校验 status 线上信封,再投影为 `{state, result}`(仅 `settled` 携带 `result`)。去掉 `protocolVersion` 与 `lastSequence`;Broker 拒绝额外字段,目前没有游标消费方。
- 对 Runtime 报告仍在运行的执行是否发送物理取消,有意推迟。

Expand All @@ -84,13 +86,13 @@ reference 是 harness 分配的原始调用身份;Runtime 不会得知任何 B

契约之上的语义:

- `execute` 按 `reference.callId` 幂等:同一身份会并入在途调用或返回其已结算结果;同一 `callId` 携带不同摘要或负载则是 409 身份冲突。未准入的工具名同样是 409——它对本代数永远不合法。`run_shell_command` 携带 `is_background: true` 时,在创建日志条目或启动进程之前被拒绝;省略或设为 false 则仍以前台运行。工具接收输入副本,参数归一化不会改变用于识别重试的原始负载。
- `execute` 按 `reference.callId` 幂等:同一身份会并入在途调用或返回其已结算结果;同一 `callId` 携带不同摘要或负载则是 409 身份冲突。未准入的工具名同样是 409——它对本代数永远不合法。`run_shell_command` 的有效输入归一化为 `is_background: true` 时,在创建日志条目或启动进程之前被拒绝,包括不区分大小写的字符串 `"true"`;省略、布尔 false 或字符串 `"false"` 则仍以前台运行。通过协议准入后的参数校验或复制失败结算为错误,不执行命令。工具接收输入副本,参数归一化不会改变用于识别重试的原始负载。
- `status` 只读,对 Runtime 没有记录的 reference 以 200 回答 `unknown`;已知调用按其状态与日志的单调 `lastSequence` 应答。
- `cancel` 把 `prepared` 调用直接结算为 cancelled(不触碰工具),中止 `executing` 调用并回答 `cancel_requested`,此后幂等。Runtime 兑现的取消会把该调用结算为 `cancelled`——无论工具把中止表现为错误还是提前返回的结果。
- worker 保留 5 秒的 HTTP `requestTimeout`,它限制接收请求体的时间,不限制完整请求的执行时间。执行由工具自身的超时约束;headers 与 keep-alive 上限维持不变。
- 发布已结算结果之前,worker 按序列化后的 status 信封检查 1 MiB 响应上限。超大输出被替换为小型终态错误(保留 cancelled 状态),并供 execute 重试、status 与 cancel 共用。这表示调用已执行但输出不可用,绝不是 `not_started`,也不是允许再次执行。
- `prepared` 是内部日志状态:执行会同步进入 `executing`,因此 HTTP 调用方无法观察或取消 prepared 条目。

验证新增 `managed-runtime-tool-worker.test.ts`:在真实挂载的路由上用原始 HTTP 回放全部负面共享 fixture;行为用例覆盖在临时工作区真实执行 `read_file`、对未见过的 reference 回答 `unknown`、并入并发的重复 execute、以 409 拒绝同 callId 不同摘要的重试、拒绝未准入工具,以及取消一个在途的前台 shell 命令。额外回归用例验证后台 shell 被拒绝且不留日志条目,并验证省略 `is_background` 与显式 false 均可准入。
验证新增 `managed-runtime-tool-worker.test.ts`:在真实挂载的路由上用原始 HTTP 回放全部负面共享 fixture;行为用例覆盖在临时工作区真实执行 `read_file`、对未见过的 reference 回答 `unknown`、并入并发的重复 execute、以 409 拒绝同 callId 不同摘要的重试、拒绝未准入工具,以及取消一个在途的前台 shell 命令。额外回归用例验证后台 shell 被拒绝且不留日志条目(包含归一化后的字符串布尔值),验证省略 `is_background`、布尔或字符串 false 均可准入,并验证无效值不会启动命令。

仍为后续工作:Harness 侧 `RuntimeBackedTool` 接线、文件历史结算、对已准入工具集合的 capability digest 校验、日志保留上限、合成模型 `managed-runtime-worker` 的图片输入支持,以及大输出的产物交付通道。
38 changes: 31 additions & 7 deletions packages/cli/src/serve/managed-runtime-tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,13 @@ export class ManagedToolConflictError extends Error {
readonly code = 'managed_runtime_identity_conflict';

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-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)

}

export class ManagedToolInvalidError extends Error {}

interface JournalEntry {
readonly reference: ManagedToolReference;
readonly toolName: string;
readonly input: Record<string, unknown>;
readonly inputJson: string;
state: ManagedToolExecutionState;
lastSequence: number;
result?: ManagedToolResultPayload;
Expand Down Expand Up @@ -106,9 +109,17 @@ export class ManagedToolExecutor {
toolName: string,
input: Record<string, unknown>,
): Promise<ManagedToolResultPayload> {
let inputJson: string;
try {
inputJson = JSON.stringify(input);
} catch {
throw new ManagedToolInvalidError(
'Managed Runtime tool request is invalid.',
);
}
const existing = this.entries.get(reference.callId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] 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)

if (existing) {
if (!sameInvocation(existing, reference, toolName, input)) {
if (!sameInvocation(existing, reference, toolName, inputJson)) {
throw new ManagedToolConflictError(
'Managed Runtime invocation identity conflicts.',
);
Expand All @@ -122,15 +133,28 @@ export class ManagedToolExecutor {
`Managed Runtime does not admit tool ${toolName}.`,
);
}
if (toolName === ShellTool.Name && input['is_background'] === true) {
throw new ManagedToolConflictError(
'Managed Runtime does not admit background shell execution.',
);
if (toolName === ShellTool.Name) {
let isBackground = false;
try {
const params = structuredClone(input);
// Admission must see the same normalized parameters as build().
isBackground =
tool.validateToolParams(params) === null &&
params['is_background'] === true;
} catch {
// Let run() journal parameter failures through its normal error path.
}
if (isBackground) {
throw new ManagedToolConflictError(
'Managed Runtime does not admit background shell execution.',
);
}
}
const entry: JournalEntry = {
reference,
toolName,
input,
inputJson,
state: 'prepared',
lastSequence: 0,
controller: new AbortController(),
Expand Down Expand Up @@ -254,12 +278,12 @@ function sameInvocation(
entry: JournalEntry,
reference: ManagedToolReference,
toolName: string,
input: Record<string, unknown>,
inputJson: string,
): boolean {
return (
sameReference(entry.reference, reference) &&
entry.toolName === toolName &&
JSON.stringify(entry.input) === JSON.stringify(input)
entry.inputJson === inputJson
);
}

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/serve/managed-runtime-tool-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from './managed-runtime-attestation-contract.js';
import {
ManagedToolConflictError,
ManagedToolInvalidError,
type ManagedToolExecutor,
type ManagedToolReference,
} from './managed-runtime-tool-executor.js';
Expand Down Expand Up @@ -142,10 +143,14 @@ export function registerManagedRuntimeToolRoutes(
);
res.status(200).json({ protocolVersion: 2, state: 'settled', result });
} catch (error) {
if (error instanceof ManagedToolInvalidError) {
invalid(res);
return;
}
if (error instanceof ManagedToolConflictError) {
res.status(409).json({
code: 'managed_runtime_identity_conflict',
error: 'Managed Runtime invocation identity conflicts.',
error: error.message,
});
return;
}
Expand Down
Loading
Loading
You are viewing a condensed version of this merge commit. You can view the full changes here.