From 3d9700345c1dc6ef1f4080cd9b783c0cc01cf3a8 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sat, 7 Nov 2026 22:25:05 +0800 Subject: [PATCH] feat(managed-agent): add Tool v4 execution measurements Extend the existing activity diagnostics with frozen executor evidence and measured native file changes. Co-authored-by: Qwen-Coder --- ...026-11-07-managed-execution-diagnostics.md | 90 ++ ...-07-managed-execution-diagnostics.zh-CN.md | 90 ++ .../managed-agent-configuration-reference.md | 1 + ...ged-agent-configuration-reference.zh-CN.md | 1 + .../cli/src/serve/hosted-runtime-recovery.ts | 15 + .../src/serve/hosted-workspace-broker.test.ts | 38 + .../cli/src/serve/hosted-workspace-broker.ts | 79 +- .../serve/hosted-workspace-tool-turn.test.ts | 60 + .../src/serve/hosted-workspace-tool-turn.ts | 31 +- .../cli/src/serve/managed-context-worker.ts | 6 +- .../cli/src/serve/managed-external-storage.ts | 4 +- .../managed-runtime-attestation-worker.ts | 15 +- .../serve/managed-runtime-tool-executor.ts | 296 ++++- .../serve/managed-runtime-tool-v3-routes.ts | 1 + .../managed-runtime-tool-v4-routes.test.ts | 1086 +++++++++++++++++ .../serve/managed-runtime-tool-v4-routes.ts | 321 +++++ .../src/serve/managed-workspace-activation.ts | 17 +- .../src/serve/workspace-recovery-session.ts | 15 +- .../managed-event-envelope-v1.fixtures.json | 19 + .../managed-event-envelope-v1.schema.json | 2 + .../managed-session-store-v1.fixtures.json | 1 + ...managed-tool-measurements-v1.fixtures.json | 307 +++++ .../http-managed-session-store.test.ts | 2 +- .../http-managed-session-store.ts | 46 +- .../managed-execution-diagnostics.test.ts | 160 ++- .../managed-session-records.test.ts | 2 +- .../managed-session-records.ts | 13 +- .../managed-tool-measurements.test.ts | 58 + .../managed-tool-measurements.ts | 314 +++++ .../configuration-descriptions.json | 4 + .../event/ManagedEventEnvelopes.java | 6 +- .../ManagedActivityDiagnostics.java | 34 + .../persistence/ManagedExtensionRecords.java | 2 +- .../persistence/ManagedStorageVersions.java | 2 +- .../persistence/ManagedToolMeasurements.java | 172 +++ .../settings/ManagedAgentSettings.java | 4 + .../RuntimeToolMeasurements.java | 100 ++ .../code/runtimebroker/RuntimeTransport.java | 51 + .../runtimebroker/ToolExecutionRecord.java | 16 + .../ToolExecutionRepository.java | 12 + .../WorkspaceExecutionProfile.java | 6 +- .../managed-agent-public-api.openapi.json | 212 ++++ .../ManagedSessionActivityService.java | 4 + .../runtime/WorkspaceRuntimeProvisioner.java | 2 +- .../runtime/WorkspaceRuntimeResolver.java | 26 +- .../runtime/WorkspaceRuntimeTransport.java | 59 +- .../persistence/JournalCommit.java | 7 +- .../persistence/JournalWriterLifecycle.java | 25 +- .../ManagedActivityDiagnosticProjection.java | 57 +- .../ManagedModelDiagnosticProjection.java | 6 +- .../ManagedNativeJournalSnapshots.java | 6 + .../persistence/WorkspaceRecoveryStore.java | 2 +- .../InMemoryToolExecutionRepository.java | 17 + .../JdbcToolExecutionObservations.java | 68 ++ .../JdbcToolExecutionRepository.java | 43 +- .../service/RuntimeBrokerService.java | 68 +- .../service/RuntimeBrokerSupport.java | 50 + .../service/RuntimeExecutionCancellation.java | 6 +- .../service/RuntimeExecutionDispatcher.java | 59 +- .../service/RuntimeExecutionReconciler.java | 80 +- .../ExternalWorkspaceProvisioner.java | 7 +- .../adapter/HttpRuntimeTransport.java | 210 +++- .../config/ManagedAgentProperties.java | 1 + .../config/ManagedSessionReaderVersion.java | 2 +- .../server/src/main/resources/application.yml | 1 + .../ManagedActivityDiagnosticsTest.java | 58 + .../ManagedAgentApiContractTest.java | 17 + .../ManagedToolMeasurementsTest.java | 113 ++ .../ManagedSessionReaderVersionTest.java | 5 + .../WorkspaceMeasurementScopeTest.java | 46 + .../WorkspaceRuntimeTransportTest.java | 58 +- .../HttpRuntimeToolV4TransportTest.java | 372 ++++++ .../ExternalWorkspaceProvisionerTest.java | 29 + .../RuntimeExecutionDispatcherTest.java | 38 +- .../RuntimeExecutionReconcilerTest.java | 68 +- .../resources/class-size-ratchet.properties | 6 +- .../managed/ManagedActivityView.tsx | 126 +- .../managed/ManagedDiagnosticsPanel.test.tsx | 54 + .../managed/generated/managed-agent-api.ts | 37 + .../managed/managed-diagnostics-messages.ts | 19 + .../client/e2e/managed-diagnostics.spec.ts | 36 +- 81 files changed, 5366 insertions(+), 233 deletions(-) create mode 100644 docs/design/2026-11-07-managed-execution-diagnostics.md create mode 100644 docs/design/2026-11-07-managed-execution-diagnostics.zh-CN.md create mode 100644 packages/cli/src/serve/managed-runtime-tool-v4-routes.test.ts create mode 100644 packages/cli/src/serve/managed-runtime-tool-v4-routes.ts create mode 100644 packages/core/src/managed-runtime/contracts/managed-tool-measurements-v1.fixtures.json create mode 100644 packages/core/src/managed-runtime/managed-tool-measurements.test.ts create mode 100644 packages/core/src/managed-runtime/managed-tool-measurements.ts create mode 100644 packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedToolMeasurements.java create mode 100644 packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeToolMeasurements.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionObservations.java create mode 100644 packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolMeasurementsTest.java create mode 100644 packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceMeasurementScopeTest.java create mode 100644 packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeToolV4TransportTest.java diff --git a/docs/design/2026-11-07-managed-execution-diagnostics.md b/docs/design/2026-11-07-managed-execution-diagnostics.md new file mode 100644 index 00000000000..c3c139e312a --- /dev/null +++ b/docs/design/2026-11-07-managed-execution-diagnostics.md @@ -0,0 +1,90 @@ +# Managed Tool v4 executor measurements + +[English](2026-11-07-managed-execution-diagnostics.md) | [简体中文](2026-11-07-managed-execution-diagnostics.zh-CN.md) + +## Status and baseline + +This design narrows PR #16498 after the author selected #16499 as the baseline. The prerequisite remains #16475; the initial integration base is `d886d0640a623928cea4507a413a1f1899bb1e8a`. Implementation and final CI acceptance are tracked separately. + +The baseline owns invocation identity, activity evidence v1, incremental SQL projections, authorized activity queries, the statistics panel and the child invocation tree. This increment extends those paths. It does not restore the earlier `execution_diagnostics` domain, a second collector, `/stats.execution`, or a second graph. + +## Remaining behavior + +An ordinary Hosted Runtime can report its actual execution duration and the native file changes it measured. The existing activity view must retain those measurements after Runtime shutdown and replay. A successful filesystem operation remains successful when formatting, output limits or delivery later fail. + +| Measurement | Meaning | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Execution duration | A nonnegative integer measured with the executor's monotonic clock around the actual invocation. Approval, queueing and result publication are excluded. | +| Model lines | Added/removed lines between the original content and the model proposal. | +| Applied lines | Added/removed lines between the original content and the final applied content. | +| File scope | Only successful native `read_file`, `write_file` and `edit`; Shell and MCP internals are not inferred. | + +Line counts accumulate per operation, including repeated edits; they are not a Git diff and undo does not subtract earlier work. Explicit zero, unchanged content, unmeasured content and missing evidence remain distinguishable. Background Shell/Monitor measurements cover only their launch invocation. Execution duration is not presented as end-to-end invocation latency, and timestamps from separate processes are never subtracted. + +The Harness compares the persisted model request with the persisted dispatch input and records bounded proposal provenance (`original / modified / unknown`). If a Hook or approval changed the input, or the original input cannot be proven, model line counts are not exposed as measured samples. Applied line counts and execution duration remain available. No original content is copied into diagnostic evidence. + +## Runtime protocol + +Tool v2/v3 remain closed and unchanged. Tool v4 exposes `execute`, `status`, `cancel` and `acknowledge` under `/internal/managed-runtime/v4/`. Its request selects an underlying execution protocol, 2 or 3. Its response wraps that original result with `managed-tool-measurements/1` and a canonical digest of the complete result and measurement packet. + +The packet contains only a nullable execution duration, a physical execution outcome and a nullable native-file measurement. File measurements contain operation/status and nullable model/applied line counts. They contain no paths, file contents, patches or Skill text. Parsing is closed, integers are bounded and the reference packet is bounded to 4 KiB. + +A new attested ordinary Workspace capability explicitly advertises v4. Existing ordinary, extension, resident and CSI digests retain their meanings. Protocol selection happens before dispatch. A lost v4 reply is followed by a status query for the same execution; it never falls back to a v2 re-execution. + +The Worker freezes result and measurement together before settlement. The Broker validates the canonical digest and persists the combined result using its existing result/version CAS before ACK. A v3 publication that settles before the measurement arrives is reconciled against the same original execution and result. ACK carries the original frozen digest; v3 capture, manifest, publication receipt and reclamation rules are preserved. + +## Existing activity projection path + +```mermaid +flowchart LR + W[Executor result and measurements] --> B[Broker durable result] + B --> H[Hosted completion writer] + H --> J[tool.measured source event] + J --> P[Existing ActivityProjection] + P --> Q[Existing authorized activity queries] + Q --> U[Existing activity view] +``` + +A dedicated `tool.measured` source event references the original `toolOutcomeRef` and an immutable `managed-tool-measurements` resource. The resource binds the packet and frozen result digest to the execution call and existing invocation identity. This is source evidence for the existing ActivityProjection, not another collector or projection domain. + +Some v3 receipts commit during Worker publication, before the v4 result freezes. Enriching those receipts afterward would change their immutable identity and closed publication contract. The separate event preserves both v2/v3 results and all original receipt bytes. It commits before model-result delivery or ACK, including blocked delivery, and does not authorize dispatch, create an invocation, invalidate Context or settle execution. A v2 results-ready checkpoint remains distinct from a receipt. + +The Harness freezes packet bytes and the measurement command identity. On recovery it first checks committed evidence; exact replay changes no total and conflicting evidence is rejected. The event and the existing activity updates share one journal transaction and the writer/activation fences. The reducer verifies kind, version, digest, byte bounds, outcome provenance and the existing execution-to-invocation link. File measurements must match the resolved native tool. No database table or public write endpoint is added, and no tool is re-run to improve diagnostics. + +After the measurement event commits, later cancellation or delivery recovery may produce a different conversation outcome. Replaying the same frozen measurement retains the original event and outcome reference. A proven Shell completion or native-file effect remains successful execution evidence even if cancellation changes the conversation result. + +Aggregates are added to the existing overall tool/file metrics and three file-operation groups. Per-tool buckets retain the baseline's bounded representation; executor measurements remain available in paginated invocation details. This avoids multiplying the retained bucket payload while preserving cumulative measured totals. + +Measurement fields are added lazily, only when measurement evidence exists. Unmeasured activity-v1 and CSI `/3` records must preserve their previous projection bytes. Internal references and digests are not exposed in activity responses; public additions contain bounded measurements, source sequence and aggregate decimal-string counters. + +## Coverage and presentation + +The existing tool/file summaries and invocation details show executor duration, valid sample counts and model/applied line changes. Means divide by measured samples, never by all requests. Missing measurement fields in older rows mean not recorded. Partial coverage is shown when recorded measurements cover only part of the relevant activity, and unsupported profiles do not become zero-valued samples. + +The measurements reuse the activity query's authorization, passive reads, pagination and `no-store` behavior. No Runtime attach, MCP discovery or filesystem read is added to queries. The existing child invocation tree and its authorization limits are unchanged. + +## Compatibility and rollout + +The new source event requires Session storage floor 5, HTTP reader version 5 and native reader capability `managed-session/3`, distinct from the database schema version. Promotion depends on the committed measurement event/reference, including replay of an already stored resource, not merely on newly uploaded bytes. New readers deploy before measurement producers; older readers cannot acquire a floor-5 Session. + +Set `QWEN_MANAGED_AGENT_RUNTIME_TOOL_MEASUREMENTS=true` (default `false`) to select the measured profile for newly resolved ordinary bindings after compatible readers and Workers deploy. Existing bindings keep their attested capability. Measurement commits also require the baseline activity evidence and a storage-version-5 grant; there is no second collector switch. + +CSI `/1`, `/2` and `/3` keep their existing storage support of 1, 3 and 4. CSI `/3` activity evidence remains supported; this increment adds no CSI executor measurement capability. An ordinary measurement profile must not widen extension/resident/CSI admission. Resource snapshots, recovery and permanent deletion retain the new reference closure through the existing mechanisms. + +## Implementation boundaries + +| Layer | Increment | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| Core | Bounded measurement contract/fixture, measurement event/reference and reference/version validation. | +| Runtime and Broker | Attested v4 wrapper, monotonic/native-file measurements, frozen result persistence, status/cancel/ACK recovery. | +| Hosted | Carry Broker measurements into live and recovered completion writers and release transient measurement state after durable commit. | +| Server | Extend the existing activity reducer, storage/reader gates, snapshot closure and existing activity response schemas. | +| WebShell | Extend current summaries/details and bilingual labels; regenerate API types. | + +## Verification and acceptance + +Shared TypeScript/Java fixtures cover integer/null/status validation and canonical digests, including UTF-8. Focused tests cover duplicate requests/results, v4 status/cancel/ACK, publication-before-measurement races, output failure after filesystem success, model/final content differences, unchanged files and legacy compatibility. Measurement binding, replay, counter replacement and snapshot closure require transactional tests. + +The test engineer runs the relevant packaged Worker path and browser presentation checks using the existing test-script fallback where no public CLI command exposes an internal contract. Presentation fixtures do not replace real Worker/Broker, MySQL or recovery acceptance. Root build/typecheck/bundle, affected TypeScript tests, API/SurfaceRegistry validation and generated-type checks remain required. + +Java verification continues the existing ten-minute ledger; it is not reset for this integration. Focused checks consume only the remaining budget. Full Java, MySQL, Hosted/fault, native CSI and SpotBugs gates remain in CI, and any unexecuted real path is reported explicitly. Success means one frozen execution contributes once, offline activity retains known measurements, and legacy/unmeasured data remains clearly distinguishable. diff --git a/docs/design/2026-11-07-managed-execution-diagnostics.zh-CN.md b/docs/design/2026-11-07-managed-execution-diagnostics.zh-CN.md new file mode 100644 index 00000000000..6e1b811a9aa --- /dev/null +++ b/docs/design/2026-11-07-managed-execution-diagnostics.zh-CN.md @@ -0,0 +1,90 @@ +# Managed Tool v4 执行方测量 + +[English](2026-11-07-managed-execution-diagnostics.md) | [简体中文](2026-11-07-managed-execution-diagnostics.zh-CN.md) + +## 状态与基线 + +作者已选择以 #16499 为基线收敛 PR #16498,本设计据此缩小范围。前置仍为 #16475,初始整合基线是 `d886d0640a623928cea4507a413a1f1899bb1e8a`。实现状态与最终 CI 验收分别记录。 + +基线负责调用身份、activity evidence v1、SQL 增量投影、授权 activity 查询、统计面板及子代理调用树。本增量扩展这些路径,不恢复此前的 `execution_diagnostics` domain、第二套 collector、`/stats.execution` 或另一张关系图。 + +## 剩余行为 + +普通 Hosted Runtime 可以报告实际执行耗时及原生文件变更测量。Runtime 关闭及重放后,现有 activity 视图仍需保留这些信息。文件系统操作已成功时,后续格式化、输出限制或交付失败不能抹去成功证据。 + +| 测量 | 含义 | +| -------- | ---------------------------------------------------------------------------------- | +| 执行耗时 | 执行方用单调时钟围绕实际调用测得的非负整数,不包含审批、排队和结果发布。 | +| 模型行数 | 原内容与模型提议之间的增删行。 | +| 实际行数 | 原内容与最终应用内容之间的增删行。 | +| 文件范围 | 仅成功的原生 `read_file`、`write_file`、`edit`,不推断 Shell 或 MCP 内部文件行为。 | + +行数按操作累计,包括重复编辑;不表示 Git diff,undo 不扣减此前工作。明确的零、无变化、未测量及证据缺失保持可区分。后台 Shell/Monitor 只测量启动调用。执行耗时不冒充完整调用响应延迟,也不将不同进程的时间戳相减。 + +Harness 比较已持久化的模型请求与 dispatch 输入,记录有界的提议来源(`original / modified / unknown`)。Hook 或审批修改了输入,或无法证明原始输入时,模型行数不作为确定的测量样本公开。实际应用行数与执行耗时仍可用。诊断证据不复制原始内容。 + +## Runtime 协议 + +Tool v2/v3 保持闭合且不变。Tool v4 在 `/internal/managed-runtime/v4/` 下提供 `execute`、`status`、`cancel`、`acknowledge`。请求选择底层执行协议 2 或 3;响应封装原结果、`managed-tool-measurements/1` 及完整结果与测量包的规范化摘要。 + +测量包只包含可空执行耗时、物理执行结果及可空原生文件测量。文件测量包括 operation/status 和可空模型/实际增删行,不携带路径、文件内容、patch 或 Skill 正文。解析保持闭合,整数有界,引用包限制为 4 KiB。 + +新的经过 attestation 的普通 Workspace capability 显式声明 v4。既有 ordinary、extension、resident 和 CSI digest 保持原义。dispatch 前确定协议;丢失 v4 响应后查询同一 execution 的状态,不降级为 v2 重新执行。 + +Worker 在 settled 前一起冻结结果和测量。Broker 校验规范化摘要,使用现有结果/version CAS 持久化完整结果后再 ACK。v3 publication 先于测量 settled 时,仍按原 execution 和结果核对归并。ACK 携带原冻结摘要;v3 capture、manifest、publication receipt 及回收规则保持不变。 + +## 现有 activity 投影路径 + +```mermaid +flowchart LR + W[执行方结果与测量] --> B[Broker 持久结果] + B --> H[Hosted 完成 writer] + H --> J[tool.measured 来源事件] + J --> P[现有 ActivityProjection] + P --> Q[现有授权 activity 查询] + Q --> U[现有 activity 视图] +``` + +专用 `tool.measured` 来源事件引用原 `toolOutcomeRef` 和不可变 `managed-tool-measurements` 资源。资源将测量包与冻结结果摘要绑定到 execution call 和已有 invocation 身份。这是现有 ActivityProjection 的来源证据,不是另一个 collector 或投影 domain。 + +部分 v3 receipt 在 Worker 发布期间提交,早于 v4 结果冻结。事后增加字段会改变其不可变身份和闭合发布契约。独立事件保留 v2/v3 结果及全部原 receipt 字节,在模型结果交付或 ACK 前提交,也覆盖交付受阻的情况。它不授权 dispatch、不创建 invocation、不使 Context 失效,也不推动执行完成。v2 的 results-ready checkpoint 仍与 receipt 区分。 + +Harness 冻结测量包字节和 command 身份;恢复时先检查已提交证据。精确重放不改变总数,证据冲突则拒绝。事件与现有 activity 更新共享 journal 原子事务和 writer/activation fence。reducer 校验 kind、version、digest、字节上限、outcome 来源和已有 execution-to-invocation 关联;文件测量必须匹配实际解析的原生工具。不增加数据库表或公开写入入口,也不为改善诊断重新执行工具。 + +测量事件提交后,取消或交付恢复可以产生不同的对话结果。相同冻结测量的重放保留原事件及 outcome 引用。即使取消改变对话返回,已证明完成的 Shell 或原生文件副作用仍保留成功执行证据。 + +聚合加入现有工具/文件总览和三种文件操作分组。按工具分桶保持基线的有界表示,执行方测量仍可在分页调用详情中读取。这样保留累计测量总数,同时避免给每个保留桶扩充测量负载。 + +仅在存在测量证据时惰性增加测量字段。未测量的 activity-v1 和 CSI `/3` 记录必须保留此前投影字节。activity 响应不公开内部引用和摘要,新增公开字段只包含有界测量、来源 sequence 和十进制字符串聚合计数。 + +## 覆盖与展示 + +现有工具/文件概要及调用详情展示执行耗时、有效样本数和模型/实际增删行。平均值以测量样本数为分母,不使用全部请求数。旧记录缺少测量字段表示未记录;测量只覆盖部分 activity 时显示部分覆盖,不支持的 profile 不产生值为零的样本。 + +测量复用 activity 查询的授权、被动读取、分页和 `no-store` 语义。查询不新增 Runtime attach、MCP discovery 或文件系统读取。既有子代理调用树及其授权限制不变。 + +## 兼容与发布 + +新的来源事件要求 Session storage floor 5、HTTP reader version 5 和原生 reader capability `managed-session/3`,与数据库 schema 版本区分。升级条件依据已提交的测量事件/reference,包括重放已存储资源,不只检查本次新上传的字节。先部署新 reader,再开启测量 producer;旧 reader 不能接管 floor-5 Session。 + +兼容 reader 和 Worker 部署后,设置 `QWEN_MANAGED_AGENT_RUNTIME_TOOL_MEASUREMENTS=true`(默认 `false`),为新解析的普通 binding 选择测量 profile。已有 binding 保留 attested capability。测量提交还要求基线 activity evidence 和 storage-version-5 grant,不增加第二个 collector 开关。 + +CSI `/1`、`/2`、`/3` 分别保留既有 storage 1、3、4 支持。CSI `/3` 的 activity evidence 继续可用,本增量不增加 CSI 执行方测量能力。普通测量 profile 不得放宽 extension/resident/CSI 准入。资源快照、恢复及永久删除沿用现有机制,保留新增引用闭包。 + +## 实现边界 + +| 层次 | 增量 | +| ----------------- | ------------------------------------------------------------------------------------- | +| Core | 有界测量契约/fixture,测量事件/引用及引用/version 校验。 | +| Runtime 与 Broker | attested v4 封装,单调时钟/原生文件测量,冻结结果持久化,status/cancel/ACK 恢复。 | +| Hosted | 将 Broker 测量接入实时及恢复完成 writer,持久提交后释放临时测量状态。 | +| Server | 扩展现有 activity reducer、storage/reader 门禁、快照闭包及既有 activity 响应 schema。 | +| WebShell | 扩展现有概要/详情和中英文文案,重新生成 API 类型。 | + +## 验证与验收 + +TypeScript/Java 共享 fixture 覆盖整数/null/status 校验和包含 UTF-8 的规范化摘要。定向测试覆盖重复请求/结果、v4 status/cancel/ACK、publication 先于测量完成的竞态、文件成功后的输出失败、模型/最终内容不同、无变化文件及旧版本兼容。测量绑定、重放、贡献替换和快照闭包需要事务测试。 + +test-engineer 运行相关打包 Worker 路径和浏览器展示检查;内部契约无公开 CLI 命令时采用既有 test-script 回退。展示 fixture 不替代真实 Worker/Broker、MySQL 或恢复验收。根 build/typecheck/bundle、受影响 TypeScript 测试、API/SurfaceRegistry 校验和生成类型检查继续为必需项。 + +Java 验证继续使用既有十分钟账本,不因本次整合重置。定向检查只消耗剩余预算。完整 Java、MySQL、Hosted/fault、native CSI 和 SpotBugs 门禁交给 CI,未运行的真实路径明确报告。成功标准为:同一次冻结执行只贡献一次,离线 activity 保留已知测量,旧数据/未测量数据仍能清楚区分。 diff --git a/docs/users/managed-agent-configuration-reference.md b/docs/users/managed-agent-configuration-reference.md index 96e2ede8f83..805173b30bc 100644 --- a/docs/users/managed-agent-configuration-reference.md +++ b/docs/users/managed-agent-configuration-reference.md @@ -262,6 +262,7 @@ The embedded Runtime Broker: its private HTTP listener for the Hosted Harness, t | `static-runtime-instance-id` | — | string | `standalone-runtime` | The Runtime instance ID of the static Runtime worker's lease; the worker's attestation must report the same value. A non-empty string of at most 512 characters. Read only when `qwen.managed-agent.runtime-broker.provisioner` is `static`. | | `static-token` | `QWEN_MANAGED_AGENT_STATIC_RUNTIME_TOKEN` | string | *(empty)* | The bearer token the Broker presents to the static Runtime worker. A secret; saved bindings store it encrypted with the Runtime credential key (`qwen.managed-agent.runtime-broker.credential-key`). Required when `qwen.managed-agent.runtime-broker.provisioner` is `static`; at most 512 characters. | | `token` | `QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN` | string | *(empty)* | The bearer token the Hosted Harness presents to the embedded Runtime Broker's private listener; it also authorizes the loopback-only external storage registration endpoint. A secret. Required when `qwen.managed-agent.runtime-broker.enabled` is true; at most 512 characters. It can come from `qwen.managed-agent.harness.credentials-file` (`QWEN_MANAGED_RUNTIME_BROKER_TOKEN`) instead; setting it in both places is refused at startup. | +| `tool-measurements` | `QWEN_MANAGED_AGENT_RUNTIME_TOOL_MEASUREMENTS` | boolean | `false` | Whether newly resolved ordinary Workspace bindings select the attested managed-workspace-execution/2 profile and Tool v4 numeric execution/file measurements. Defaults to false; existing bindings, extension/MCP owners, resident and CSI profiles retain their contracts. Deploy compatible Worker/Broker readers first. Measurement commits reuse existing activity evidence and require a storage-version-5 grant; no separate collector flag is introduced. See the [execution diagnostics design](../design/2026-11-07-managed-execution-diagnostics.md). | | `trusted-local-reboot-recovery` | `QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY` | boolean | `true` | Whether, after a whole-host reboot on Linux, the Broker treats the changed kernel boot ID as proof that the original local workers stopped, and clears the holders of their saved Runtime bindings once every execution is terminal. It scans 8 saved bindings every 5 seconds and never starts a replacement worker or replays an execution. Requires `qwen.managed-agent.runtime-broker.durable-local-process` and the `local-process` provisioner; startup refuses it otherwise, on macOS and with resident Workspaces. | | `v3-result-window` | `QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW` | duration | `30m` | How long the Runtime Broker keeps waiting for the worker's result of a dispatched Shell or Monitor execution that publishes its output (Tool v3, with `qwen.managed-agent.tool-publication.enabled`) before marking it `UNKNOWN`; a value shorter than the longest such call turns that call into `UNKNOWN`. Other tool executions do not use this window. A value above `30m` buys nothing on the shipped path: the Hosted Harness stops waiting for a Tool v3 execution after its own fixed 30 minutes. A suffix-less number binds as seconds. Startup refuses a value below `1s`. | | `verified-workspace-recovery-enabled` | `QWEN_MANAGED_AGENT_RUNTIME_VERIFIED_WORKSPACE_RECOVERY_ENABLED` | boolean | `false` | Whether LOCAL Workspace execution is gated on a verified physical storage registration: before tools, file access or child Git run, the server checks that the Workspace root still matches its registered identity and marker, and otherwise refuses with `409 workspace_unavailable`. Required by `qwen.managed-agent.runtime-broker.workspace-creation-root` and `qwen.managed-agent.runtime-broker.local-directory-import-enabled`; without `qwen.managed-agent.runtime-broker.workspace-mounts` it also satisfies the Workspace source that `qwen.managed-agent.harness.workspace-files-enabled` needs. Startup refuses it with external provisioners and resident Workspaces. | diff --git a/docs/users/managed-agent-configuration-reference.zh-CN.md b/docs/users/managed-agent-configuration-reference.zh-CN.md index 4adaf030151..5d7568dec8d 100644 --- a/docs/users/managed-agent-configuration-reference.zh-CN.md +++ b/docs/users/managed-agent-configuration-reference.zh-CN.md @@ -262,6 +262,7 @@ Workspace 扩展包的发布与管理,以及哪些 actor 有权执行这些操 | `static-runtime-instance-id` | — | 字符串 | `standalone-runtime` | static Runtime worker 租约中的 Runtime 实例 ID;worker 的证明(attestation)必须回报相同的值。须为非空字符串,最长 512 个字符。仅当 `qwen.managed-agent.runtime-broker.provisioner` 为 `static` 时读取。 | | `static-token` | `QWEN_MANAGED_AGENT_STATIC_RUNTIME_TOKEN` | 字符串 | *(空)* | Broker 访问 static Runtime worker 时出示的 Bearer 令牌,属于密钥;已保存的绑定用 Runtime 凭据密钥(`qwen.managed-agent.runtime-broker.credential-key`)加密存储它。`qwen.managed-agent.runtime-broker.provisioner` 为 `static` 时必填,最长 512 个字符。 | | `token` | `QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN` | 字符串 | *(空)* | Hosted Harness 访问内嵌 Runtime Broker 私有监听端口时出示的 Bearer 令牌;它也用于授权仅限回环地址的外部存储登记接口。属于密钥。`qwen.managed-agent.runtime-broker.enabled` 为 true 时必填,最长 512 个字符。也可以改由 `qwen.managed-agent.harness.credentials-file` 中的 `QWEN_MANAGED_RUNTIME_BROKER_TOKEN` 提供;两处同时设置时启动时拒绝。 | +| `tool-measurements` | `QWEN_MANAGED_AGENT_RUNTIME_TOOL_MEASUREMENTS` | 布尔 | `false` | 是否让新解析的普通 Workspace binding 选择经过 attestation 的 managed-workspace-execution/2 profile 和 Tool v4 数值执行/文件测量。默认 false;已有 binding、extension/MCP owner、resident 和 CSI profile 保留原契约。先部署兼容 Worker/Broker reader。测量提交复用现有 activity evidence,并要求 storage grant 支持 version 5;不引入独立 collector 开关。参见[执行诊断设计](../design/2026-11-07-managed-execution-diagnostics.zh-CN.md)。 | | `trusted-local-reboot-recovery` | `QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY` | 布尔 | `true` | Linux 主机整机重启后,Broker 是否把内核 boot ID 的变化视为原本地 worker 已停止的证据,并在所有执行都已终结后清除这些已保存 Runtime 绑定的持有者。它每 5 秒扫描 8 个已保存的绑定,从不启动替代 worker,也不重放执行。要求 `qwen.managed-agent.runtime-broker.durable-local-process` 和 `local-process` provisioner;不满足条件、在 macOS 上或配置了常驻 Workspace 时启动时拒绝。 | | `v3-result-window` | `QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW` | 时长 | `30m` | Runtime Broker 等待 worker 返回已派发的、会发布输出的 Shell 或 Monitor 执行(Tool v3,开启 `qwen.managed-agent.tool-publication.enabled` 时使用)结果的最长时间,超时后把该执行标记为 `UNKNOWN`;若短于最长的此类调用,该调用会变成 `UNKNOWN`。其他工具执行不使用此窗口。大于 `30m` 在随发行版提供的执行路径上没有作用:Hosted Harness 自身等待 Tool v3 执行的期限固定为 30 分钟。不带单位的数字按秒解析。低于 `1s` 时启动时拒绝。 | | `verified-workspace-recovery-enabled` | `QWEN_MANAGED_AGENT_RUNTIME_VERIFIED_WORKSPACE_RECOVERY_ENABLED` | 布尔 | `false` | LOCAL Workspace 的执行是否以已验证的物理存储登记为前提:在工具、文件访问或子 Git 操作运行前,服务端检查 Workspace 根目录是否仍与登记的身份和标记一致,否则以 `409 workspace_unavailable` 拒绝。`qwen.managed-agent.runtime-broker.workspace-creation-root` 和 `qwen.managed-agent.runtime-broker.local-directory-import-enabled` 都依赖它;未配置 `qwen.managed-agent.runtime-broker.workspace-mounts` 时,它也满足 `qwen.managed-agent.harness.workspace-files-enabled` 对 Workspace 来源的要求。与外部 provisioner 或常驻 Workspace 同时使用时启动时拒绝。 | diff --git a/packages/cli/src/serve/hosted-runtime-recovery.ts b/packages/cli/src/serve/hosted-runtime-recovery.ts index 7ce85617195..0e218561817 100644 --- a/packages/cli/src/serve/hosted-runtime-recovery.ts +++ b/packages/cli/src/serve/hosted-runtime-recovery.ts @@ -5,6 +5,7 @@ */ import { randomUUID } from 'node:crypto'; +import { recordManagedToolMeasurements } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-measurements.js'; import { parseManagedConfigInstallRecord } from '@qwen-code/qwen-code-core/managed-runtime/managed-config-install.js'; import { expandHostedExtensionCommand } from './hosted-extension-command.js'; import { isDeepStrictEqual } from 'node:util'; @@ -576,6 +577,13 @@ export async function settleParkedTurnCancelled(input: { 'managed-tool-outcome', outcomeBytes(item, parts), )); + await recordManagedToolMeasurements( + input.session, + item.executionCallId, + outcomeRef, + input.broker?.toolMeasurements(item.executionCallId), + ); + input.broker?.releaseToolMeasurements(item.executionCallId); // The assistant's functionCall must meet its functionResponse in the next // turn's history, or providers reject the request as malformed. if (!journaled.has(item.functionCallId)) { @@ -3007,6 +3015,13 @@ export async function recoverHostedRuntimeTurn(input: { 'managed-tool-outcome', outcome, ); + await recordManagedToolMeasurements( + session, + item.executionCallId, + outcomeRef, + broker.toolMeasurements(item.executionCallId), + ); + broker.releaseToolMeasurements(item.executionCallId); if (!journaled.has(item.functionCallId)) { await writeRecoveryRecord(session, record); } diff --git a/packages/cli/src/serve/hosted-workspace-broker.test.ts b/packages/cli/src/serve/hosted-workspace-broker.test.ts index 8a8a92cfb11..0f420cb22cd 100644 --- a/packages/cli/src/serve/hosted-workspace-broker.test.ts +++ b/packages/cli/src/serve/hosted-workspace-broker.test.ts @@ -60,6 +60,44 @@ it('resolves and pins Harness capability once without acquiring or warming a Run expect(broker.runtime).toBeUndefined(); }); +it('retains frozen measurements observed by a cancellation status poll', async () => { + const metadata = { + protocolVersion: 4, + executionProtocol: 2, + resultDigest: 'a'.repeat(64), + measurements: { + protocol: 'managed-tool-measurements/1', + executionDurationMs: 0, + executionOutcome: 'success', + file: null, + }, + }; + const broker = await fixture(() => ({ + body: { + ...identity, + executionCallId: 'execution', + status: { + state: 'settled', + result: { + executionStatus: 'success', + responseParts: [], + diagnostics: metadata, + }, + }, + }, + })); + expect(await broker.status('execution')).toEqual({ + state: 'settled', + executionStatus: 'success', + }); + expect(broker.toolMeasurements('execution')).toEqual({ + resultDigest: metadata.resultDigest, + measurements: metadata.measurements, + }); + broker.releaseToolMeasurements('execution'); + expect(broker.toolMeasurements('execution')).toBeUndefined(); +}); + it('reads a fresh saved Workspace context after capabilities were cached', async () => { let generation = 0; const broker = await fixture(() => ({ diff --git a/packages/cli/src/serve/hosted-workspace-broker.ts b/packages/cli/src/serve/hosted-workspace-broker.ts index 269a081dae0..f1bf8611ac3 100644 --- a/packages/cli/src/serve/hosted-workspace-broker.ts +++ b/packages/cli/src/serve/hosted-workspace-broker.ts @@ -22,9 +22,14 @@ import type { import { parseToolResultEnvelope } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-result.js'; import type { LocalShellReceipt } from '@qwen-code/qwen-code-core/managed-runtime/local-shell-result-session.js'; import type { ManagedToolResultPayload } from './managed-runtime-tool-executor.js'; +import { + parseManagedToolMeasurements, + type ManagedToolMeasurements, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-measurements.js'; import { resolveManagedRuntimeBrokerBaseUrl } from './managed-runtime-broker-url.js'; import { WORKSPACE_CAPABILITY_DIGEST, + MEASURED_WORKSPACE_CAPABILITY_DIGEST, RESIDENT_WORKSPACE_CAPABILITY_DIGEST, } from './managed-workspace-activation.js'; import type { @@ -135,6 +140,43 @@ export class HostedWorkspaceBroker { private readonly baseUrl: URL; private leaseAcquired = false; private capability?: string; + private readonly measuredResults = new Map< + string, + { measurements: ManagedToolMeasurements | null; resultDigest: string } + >(); + + releaseToolMeasurements(id: string): void { + this.measuredResults.delete(id); + } + + toolMeasurements(id: string) { + return this.measuredResults.get(id); + } + + private readToolResult(id: string, value: unknown): Record { + const result = object(value); + const { diagnostics, ...base } = result; + if (diagnostics !== undefined) { + const metadata = object(diagnostics); + if ( + metadata['protocolVersion'] !== 4 || + ![2, 3].includes(metadata['executionProtocol'] as number) || + Object.keys(metadata).sort().join(',') !== + 'executionProtocol,measurements,protocolVersion,resultDigest' || + typeof metadata['resultDigest'] !== 'string' || + !/^[0-9a-f]{64}$/.test(metadata['resultDigest']) + ) + throw new Error('Invalid measured tool result.'); + this.measuredResults.set(id, { + measurements: + metadata['measurements'] === null + ? null + : parseManagedToolMeasurements(metadata['measurements']), + resultDigest: metadata['resultDigest'], + }); + } + return base; + } private capabilityResolution?: Promise; provisionerKind?: string; private readonly identity: { @@ -335,6 +377,7 @@ export class HostedWorkspaceBroker { scope['tenantId'] !== this.key.tenantId || scope['workspaceId'] !== this.key.workspaceId || (scope['capabilityDigest'] !== WORKSPACE_CAPABILITY_DIGEST && + scope['capabilityDigest'] !== MEASURED_WORKSPACE_CAPABILITY_DIGEST && scope['capabilityDigest'] !== MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST && scope['capabilityDigest'] !== @@ -412,6 +455,9 @@ export class HostedWorkspaceBroker { promptId: turnId, callId, argsDigest: digest, + ...(this.capability === MEASURED_WORKSPACE_CAPABILITY_DIGEST + ? { resultProtocol: 4 } + : {}), ...(inputDigest ? { runtimeProtocol: 3, inputDigest } : {}), }, }; @@ -481,7 +527,13 @@ export class HostedWorkspaceBroker { const status = object(response['status']); if (status['state'] === 'prepared') return { executionCallId: id }; if (status['state'] === 'settled') { - return { executionCallId: id, settled: settledResult(status) }; + return { + executionCallId: id, + settled: settledResult({ + ...status, + result: this.readToolResult(id, status['result']), + }), + }; } throw new Error( 'Hosted Workspace Broker answered a reservation that is neither fresh nor settled.', @@ -516,6 +568,9 @@ export class HostedWorkspaceBroker { promptId: turnId, callId, argsDigest, + ...(this.capability === MEASURED_WORKSPACE_CAPABILITY_DIGEST + ? { resultProtocol: 4 } + : {}), }, }); const executionCallId = response['executionCallId']; @@ -576,7 +631,7 @@ export class HostedWorkspaceBroker { throw new Error('Tool v3 execution identity changed.'); const status = object(response['status']); if (status['state'] === 'settled') { - const result = object(status['result']); + const result = this.readToolResult(id, status['result']); if ( !['success', 'error', 'cancelled', 'not_started'].includes( String(result['executionStatus']), @@ -704,7 +759,7 @@ export class HostedWorkspaceBroker { throw new Error('Runtime execution identity changed.'); const status = object(response['status']); if (status['state'] === 'settled') { - const result = object(status['result']); + const result = this.readToolResult(id, status['result']); if ( !['success', 'error', 'cancelled', 'not_started'].includes( String(result['executionStatus']), @@ -754,7 +809,13 @@ export class HostedWorkspaceBroker { const status = object(response['status']); const state = String(status['state']); return state === 'settled' - ? { state, settled: settledResult(status) } + ? { + state, + settled: settledResult({ + ...status, + result: this.readToolResult(id, status['result']), + }), + } : { state }; } @@ -953,6 +1014,14 @@ export class HostedWorkspaceBroker { ) throw new Error('Runtime execution outcome is unknown.'); const result = status['result']; + if ( + state === 'settled' && + result && + typeof result === 'object' && + !Array.isArray(result) + ) { + this.readToolResult(id, result); + } const executionStatus = state === 'settled' && typeof result === 'object' && @@ -1002,7 +1071,7 @@ export class HostedWorkspaceBroker { 'Original Shell result is not settled under its binding.', ); } - const result = object(status['result']); + const result = this.readToolResult(id, status['result']); if (!('capture' in result)) { const error = 'error' in result ? object(result['error']) : undefined; if ( diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts index cebb7412ba6..f8444171af1 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts @@ -97,12 +97,15 @@ import { } from '@qwen-code/qwen-code-core/managed-runtime/managed-execution-diagnostics.js'; import { RESIDENT_WORKSPACE_CAPABILITY_DIGEST, + MEASURED_WORKSPACE_CAPABILITY_DIGEST, WORKSPACE_CAPABILITY_DIGEST, } from './managed-workspace-activation.js'; const broker = vi.hoisted(() => ({ capabilityDigest: 'sha256:7df3981fa14b397fe09f6ba84ed0c096c71902394da667bf47f3b315866e7f91', + toolMeasurements: vi.fn(), + releaseToolMeasurements: vi.fn(), fileHistory: vi.fn(), authorizeLifecycle: vi.fn(), workspaceContext: vi.fn(), @@ -140,6 +143,8 @@ vi.mock('./hosted-workspace-broker.js', async (importOriginal) => ({ // request leaves, so a lost reply still names it. private claim?: string; mountClaim = () => this.claim; + toolMeasurements = broker.toolMeasurements; + releaseToolMeasurements = broker.releaseToolMeasurements; fileHistory = broker.fileHistory; authorizeLifecycle = broker.authorizeLifecycle; workspaceContext = broker.workspaceContext; @@ -546,6 +551,61 @@ it.each([ }, ); +it('persists frozen executor measurements before model result delivery fails', async () => { + await enableManagedExecutionDiagnostics(session); + broker.capabilityDigest = MEASURED_WORKSPACE_CAPABILITY_DIGEST; + broker.toolMeasurements.mockReturnValue({ + resultDigest: 'a'.repeat(64), + measurements: { + protocol: 'managed-tool-measurements/1', + executionDurationMs: 0, + executionOutcome: 'success', + file: { + operation: 'read', + status: 'measured', + modelLinesAdded: null, + modelLinesRemoved: null, + appliedLinesAdded: null, + appliedLinesRemoved: null, + }, + }, + }); + const original = commit; + commit = async (...args) => { + if (args[0] === 'tool_result') throw new Error('delivery unavailable'); + return original(...args); + }; + turn = createTurn(); + await expect( + turn.execute([calls[0]], [parts[0]], 'model', new AbortController().signal), + ).rejects.toThrow(); + const event = session.authority.lastEventOfKind('tool.measured')!; + expect(event).toBeDefined(); + const packet = JSON.parse( + ( + await session.resources.read( + assertManagedSessionDurableRef( + event.payload['measurementsRef'], + 'measurement', + ), + ) + ).toString(), + ); + expect(packet.measurements).toMatchObject({ + executionDurationMs: 0, + executionOutcome: 'success', + file: { operation: 'read' }, + }); + expect(broker.releaseToolMeasurements).toHaveBeenCalledWith( + event.payload['executionCallId'], + ); + expect( + session.authority.committedReceipt( + String(event.payload['executionCallId']), + ), + ).toBeUndefined(); +}); + it('refuses a mixed Skill and Workspace batch before approval Hooks or tool dispatch', async () => { const fire = vi.fn().mockResolvedValue(undefined); turn = createTurn( diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.ts index 28581ae4abc..e1b1ee02af3 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.ts @@ -24,7 +24,10 @@ import { waitForHostedQuestion, } from './hosted-question-wait.js'; import { probeNeverStarted } from './hosted-never-started-reconcile.js'; -import { RESIDENT_WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js'; +import { + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + RESIDENT_WORKSPACE_CAPABILITY_DIGEST, +} from './managed-workspace-activation.js'; import { MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST, @@ -120,6 +123,7 @@ import { type ManagedSessionDomain, type ManagedSessionDurableRef, } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; +import { recordManagedToolMeasurements } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-measurements.js'; import type { ManagedHarnessHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-factory.js'; import { harnessCommandLocked, @@ -6041,6 +6045,10 @@ export class HostedWorkspaceToolTurn { ? await this.publisher!.receipt(executionCallId, shellResult) : undefined; if (receipt?.deliveryStatus === 'blocked') { + await this.recordToolMeasurements( + executionCallId, + receipt.outcomeRef, + ); await this.broker.acknowledge(executionCallId, receipt); throw new Error('Complete Shell output was not admitted.'); } @@ -6148,6 +6156,7 @@ export class HostedWorkspaceToolTurn { 'managed-tool-outcome', outcome, )); + await this.recordToolMeasurements(executionCallId, outcomeRef); await this.commit('tool_result', converted, model); await this.harness.resolveAwaitRuntime(executionCallId, outcomeRef); if (receipt) await this.broker.acknowledge(executionCallId, receipt); @@ -6472,6 +6481,7 @@ export class HostedWorkspaceToolTurn { 'managed-tool-outcome', Buffer.from(JSON.stringify({ executionCallId, ...parts[0] })), ); + await this.recordToolMeasurements(executionCallId, outcomeRef); await this.commit('tool_result', parts, model); await this.harness.resolveAwaitRuntime(executionCallId, outcomeRef); } @@ -6728,6 +6738,7 @@ export class HostedWorkspaceToolTurn { } if (!receipt) throw new Error('Original Monitor watch receipt disappeared.'); + await this.recordToolMeasurements(executionCallId, ref); await this.commit('tool_result', converted, model, { uuid: messageId, timestamp, @@ -8452,6 +8463,7 @@ export class HostedWorkspaceToolTurn { } if (!receipt) throw new Error('Original background Shell receipt disappeared.'); + await this.recordToolMeasurements(executionCallId, ref); await this.commit('tool_result', converted, model, { uuid: messageId, timestamp, @@ -8473,6 +8485,21 @@ export class HostedWorkspaceToolTurn { return converted; } + private async recordToolMeasurements( + executionCallId: string, + outcomeRef: ManagedSessionDurableRef, + ): Promise { + if (this.broker.capabilityDigest !== MEASURED_WORKSPACE_CAPABILITY_DIGEST) + return; + await recordManagedToolMeasurements( + this.session, + executionCallId, + outcomeRef, + this.broker.toolMeasurements(executionCallId), + ); + this.broker.releaseToolMeasurements(executionCallId); + } + private async acceptShell( call: ToolCallRequestInfo, executionCallId: string, @@ -8621,6 +8648,7 @@ export class HostedWorkspaceToolTurn { } if (!receipt) throw new Error('Original unstarted Shell receipt disappeared.'); + await this.recordToolMeasurements(executionCallId, ref); await this.commit('tool_result', converted, model, { uuid: messageId, timestamp, @@ -8797,6 +8825,7 @@ export class HostedWorkspaceToolTurn { ); } if (!receipt) throw new Error('Original Shell receipt disappeared.'); + await this.recordToolMeasurements(executionCallId, ref); await this.commit('tool_result', converted, model, { uuid: messageId, timestamp, diff --git a/packages/cli/src/serve/managed-context-worker.ts b/packages/cli/src/serve/managed-context-worker.ts index 116dcb9ca6b..395bac738f0 100644 --- a/packages/cli/src/serve/managed-context-worker.ts +++ b/packages/cli/src/serve/managed-context-worker.ts @@ -72,6 +72,7 @@ import type { ManagedShellPublisherRegistry } from './managed-shell-publisher.js import { ManagedRuntimeLedger } from './managed-runtime-ledger.js'; import { registerManagedRuntimeToolRoutes } from './managed-runtime-tool-routes.js'; import { registerManagedRuntimeToolV3Routes } from './managed-runtime-tool-v3-routes.js'; +import { registerManagedRuntimeToolV4Routes } from './managed-runtime-tool-v4-routes.js'; import { MANAGED_RUNTIME_PROVIDER_ROUTE, ManagedRuntimeProviderProtocolError, @@ -80,7 +81,7 @@ import { registerManagedRuntimeProviderRoute } from './managed-runtime-provider- import { WorkspaceActivations, WORKSPACE_ACTIVATION_ROUTE, - WORKSPACE_CAPABILITY_DIGEST, + isWorkspaceToolCapability, WORKSPACE_CONTEXT_CONFIG_REF, RESIDENT_WORKSPACE_CAPABILITY_DIGEST, } from './managed-workspace-activation.js'; @@ -385,7 +386,7 @@ export function registerManagedContextRoutes( if (resident && boot.isolationClass !== 'workspace') throw new Error('Resident Workspace requires Workspace isolation.'); const requiresActivation = - boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || + isWorkspaceToolCapability(boot.capabilityDigest) || resident || extensionConfiguration !== undefined; const admissionOpen = (): boolean => executor.isAdmissionOpen; @@ -1071,6 +1072,7 @@ export function registerManagedContextRoutes( activations.register(app, boot, installations, executor); registerManagedRuntimeToolRoutes(app, boot, executor); + registerManagedRuntimeToolV4Routes(app, boot, executor); if (publisher) { registerManagedRuntimeToolV3Routes(app, boot, executor); } diff --git a/packages/cli/src/serve/managed-external-storage.ts b/packages/cli/src/serve/managed-external-storage.ts index 383e481cb8d..2bbfc69a5e6 100644 --- a/packages/cli/src/serve/managed-external-storage.ts +++ b/packages/cli/src/serve/managed-external-storage.ts @@ -13,7 +13,7 @@ import { } from './managed-context-envelope.js'; import { publisherFields } from './managed-shell-publisher.js'; import { realpathDeepestExisting } from './managed-runtime-tool-executor.js'; -import { WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js'; +import { isWorkspaceToolCapability } from './managed-workspace-activation.js'; const INVALID = 'Managed Runtime external storage admission failed.'; const directories = new WeakMap(); @@ -91,7 +91,7 @@ export async function readManagedExternalBoot( if ( boot.isolationClass !== 'session' || boot.mountRoot !== storage.mountRoot || - boot.capabilityDigest !== WORKSPACE_CAPABILITY_DIGEST + !isWorkspaceToolCapability(boot.capabilityDigest) ) throw new Error(INVALID); const directory = path.dirname(bootPath); diff --git a/packages/cli/src/serve/managed-runtime-attestation-worker.ts b/packages/cli/src/serve/managed-runtime-attestation-worker.ts index 9deff1441d5..0a933bd81d1 100644 --- a/packages/cli/src/serve/managed-runtime-attestation-worker.ts +++ b/packages/cli/src/serve/managed-runtime-attestation-worker.ts @@ -45,7 +45,11 @@ import { } from './managed-extension-runtime.js'; import { MANAGED_EXTENSION_COMMAND_ROUTE } from './managed-extension-command-runtime.js'; import { PUBLICATION_INSTALL_ROUTE } from './remote-shell-result-publication.js'; -import { WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js'; +import { + isWorkspaceToolCapability, + MEASURED_WORKSPACE_CAPABILITY_DIGEST, +} from './managed-workspace-activation.js'; +import { MANAGED_RUNTIME_TOOL_V4_ROUTES } from './managed-runtime-tool-v4-routes.js'; import { ManagedShellPublisherRegistry, MANAGED_SHELL_PUBLISHER_ROUTE, @@ -384,10 +388,13 @@ export async function startManagedRuntimeAttestationWorker( boot.version === 2 ? capturePublisher || remotePublishers || - boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || + isWorkspaceToolCapability(boot.capabilityDigest) || boot.managedExtensions !== undefined ? [ ...MANAGED_CONTEXT_WORKER_ROUTES, + ...(boot.capabilityDigest === MEASURED_WORKSPACE_CAPABILITY_DIGEST + ? MANAGED_RUNTIME_TOOL_V4_ROUTES + : []), ...(boot.managedExtensions ? [ MANAGED_EXTENSION_RUNTIME_ROUTE, @@ -408,7 +415,7 @@ export async function startManagedRuntimeAttestationWorker( ...(remotePublishers ? [MANAGED_SHELL_PUBLISHER_ROUTE] : []), ...(!capturePublisher && !publication && - (boot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || + (isWorkspaceToolCapability(boot.capabilityDigest) || boot.managedExtensions !== undefined) ? [PUBLICATION_INSTALL_ROUTE] : []), @@ -689,7 +696,7 @@ export async function runManagedRuntimeAttestationWorker( undefined, contextBoot.version === 2 && !('storage' in contextBoot) && - (contextBoot.capabilityDigest === WORKSPACE_CAPABILITY_DIGEST || + (isWorkspaceToolCapability(contextBoot.capabilityDigest) || contextBoot.managedExtensions !== undefined) ? new ManagedShellPublisherRegistry() : undefined, diff --git a/packages/cli/src/serve/managed-runtime-tool-executor.ts b/packages/cli/src/serve/managed-runtime-tool-executor.ts index 9a0d7dd5847..2ca6dbb4b3d 100644 --- a/packages/cli/src/serve/managed-runtime-tool-executor.ts +++ b/packages/cli/src/serve/managed-runtime-tool-executor.ts @@ -39,6 +39,11 @@ import { import { managedToolDigest } from '@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'; import type { ShellToolInvocation } from '@qwen-code/qwen-code-core/tools/shell.js'; import type { ToolResultEnvelope } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-result.js'; +import type { + ManagedToolMeasurements, + ManagedFileMeasurement, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-measurements.js'; +import { getDiffStat } from '@qwen-code/qwen-code-core/tools/diffOptions.js'; import type { LocalShellCaptureRequest } from '@qwen-code/qwen-code-core/managed-runtime/managed-shell-result-session.js'; import type { LocalShellResultCapture } from '@qwen-code/qwen-code-core/managed-runtime/local-shell-result-capture.js'; import type { ToolResultExpectedIdentity } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-result-store.js'; @@ -57,6 +62,7 @@ import { } from './managed-runtime-ledger.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core/utils/debugLogger.js'; import { getShellConfiguration } from '@qwen-code/qwen-code-core/utils/shell-utils.js'; +import { isShellResultDisplay } from '@qwen-code/qwen-code-core/utils/shell-result.js'; import { getShellContextEnvVars } from '@qwen-code/qwen-code-core/services/shellContextEnv.js'; import type { AnyDeclarativeTool, @@ -293,6 +299,8 @@ interface JournalEntry { state: ManagedToolExecutionState; lastSequence: number; result?: ManagedToolResultPayload; + measurements?: ManagedToolMeasurements; + readonly measure?: true; v3Result?: ToolResultEnvelope; readonly v3Capture?: LocalShellCaptureRequest['capture']; readonly captureSink?: ManagedShellCaptureSink; @@ -1000,9 +1008,10 @@ export class ManagedToolExecutor { reference: ManagedToolReference, toolName: string, input: Record, + measure?: true, ): Promise { return this.trackStart(() => - this.executeAdmitted(reference, toolName, input), + this.executeAdmitted(reference, toolName, input, measure), ); } @@ -1010,6 +1019,7 @@ export class ManagedToolExecutor { reference: ManagedToolReference, toolName: string, input: Record, + measure?: true, ): Promise { if ( this.options.sshForegroundOnly && @@ -1051,7 +1061,7 @@ export class ManagedToolExecutor { 'Managed MCP Session is unavailable.', ); if (this.mcpCalls.has(reference.callId)) - return this.execute(reference, toolName, input); + return this.execute(reference, toolName, input, measure); const control = parseManagedMcpControl(input); if ( control.kind !== 'mcp-invoke' || @@ -1098,7 +1108,7 @@ export class ManagedToolExecutor { } const existing = this.entries.get(reference.callId); if (existing) { - if (existing.version !== 2) { + if (existing.version !== 2 || existing.measure !== measure) { throw new ManagedToolConflictError( 'Managed Runtime protocol conflicts.', ); @@ -1114,7 +1124,7 @@ export class ManagedToolExecutor { // A concurrent execute of the same call may have journaled it meanwhile. const joined = this.entries.get(reference.callId); if (joined) { - if (joined.version !== 2) { + if (joined.version !== 2 || joined.measure !== measure) { throw new ManagedToolConflictError( 'Managed Runtime protocol conflicts.', ); @@ -1156,6 +1166,7 @@ export class ManagedToolExecutor { } const entry: JournalEntry = { version: 2, + measure, reference, toolName, input, @@ -1173,6 +1184,7 @@ export class ManagedToolExecutor { request: LocalShellCaptureRequest & { readonly toolName: string; readonly input: Record; + readonly measure?: true; }, ): Promise { return this.trackStart(() => this.executeV3Admitted(request)); @@ -1182,6 +1194,7 @@ export class ManagedToolExecutor { request: LocalShellCaptureRequest & { readonly toolName: string; readonly input: Record; + readonly measure?: true; }, ): Promise { if (this.closing) { @@ -1222,6 +1235,7 @@ export class ManagedToolExecutor { if (existing) { if ( existing.version !== 3 || + existing.measure !== request.measure || !sameReference(existing.reference, reference) || existing.toolName !== toolName || !sameCapture(existing.v3Capture, capture) @@ -1300,6 +1314,7 @@ export class ManagedToolExecutor { } const entry: JournalEntry = { version: 3, + measure: request.measure, reference, toolName, input, @@ -1328,6 +1343,7 @@ export class ManagedToolExecutor { request: LocalShellCaptureRequest & { readonly toolName: string; readonly input: Record; + readonly measure?: true; }, tools: ManagedToolSet, normalized: Record, @@ -1338,6 +1354,7 @@ export class ManagedToolExecutor { if (this.entries.has(reference.callId)) return this.executeV3(request); const entry: JournalEntry = { version: 3, + measure: request.measure, reference, toolName: request.toolName, input, @@ -1348,7 +1365,15 @@ export class ManagedToolExecutor { controller: new AbortController(), }; this.entries.set(reference.callId, entry); + let executionDurationMs: number | null = null; const settle = (result: ToolResultEnvelope): ManagedToolV3View => { + if (entry.measure) + entry.measurements = { + protocol: 'managed-tool-measurements/1', + executionDurationMs, + executionOutcome: result.executionStatus, + file: null, + }; entry.v3Result = result; entry.state = 'settled'; entry.lastSequence = 1; @@ -1464,6 +1489,7 @@ export class ManagedToolExecutor { } } }; + const launchStarted = entry.measure ? performance.now() : undefined; try { process = await this.backgroundSupervisor.start({ unitName, @@ -1500,7 +1526,19 @@ export class ManagedToolExecutor { applyPause(true); }, }); + executionDurationMs = Math.max( + 0, + Math.floor( + launchStarted === undefined ? 0 : performance.now() - launchStarted, + ), + ); } catch (cause) { + executionDurationMs = Math.max( + 0, + Math.floor( + launchStarted === undefined ? 0 : performance.now() - launchStarted, + ), + ); return settle({ executionStatus: 'not_started', responseParts: [], @@ -1555,6 +1593,7 @@ export class ManagedToolExecutor { request: LocalShellCaptureRequest & { readonly toolName: string; readonly input: Record; + readonly measure?: true; }, tools: ManagedToolSet, normalized: Record, @@ -1563,6 +1602,7 @@ export class ManagedToolExecutor { if (this.entries.has(reference.callId)) return this.executeV3(request); const entry: JournalEntry = { version: 3, + measure: request.measure, reference, toolName: request.toolName, input, @@ -1573,7 +1613,15 @@ export class ManagedToolExecutor { controller: new AbortController(), }; this.entries.set(reference.callId, entry); + let executionDurationMs: number | null = null; const settle = (result: ToolResultEnvelope): ManagedToolV3View => { + if (entry.measure) + entry.measurements = { + protocol: 'managed-tool-measurements/1', + executionDurationMs, + executionOutcome: result.executionStatus, + file: null, + }; entry.v3Result = result; entry.state = 'settled'; entry.lastSequence = 1; @@ -1702,6 +1750,7 @@ export class ManagedToolExecutor { if (!paused && bufferedBytes >= MANAGED_BACKGROUND_OUTPUT_PAUSE_BYTES) applyPause(true); }; + const launchStarted = entry.measure ? performance.now() : undefined; try { watchHandle = await this.monitorWatcher.start( { command }, @@ -1712,7 +1761,19 @@ export class ManagedToolExecutor { () => undefined, { unitName, cwd: directory, onChunk: acceptChunk }, ); + executionDurationMs = Math.max( + 0, + Math.floor( + launchStarted === undefined ? 0 : performance.now() - launchStarted, + ), + ); } catch (cause) { + executionDurationMs = Math.max( + 0, + Math.floor( + launchStarted === undefined ? 0 : performance.now() - launchStarted, + ), + ); return settle({ executionStatus: 'not_started', responseParts: [], @@ -1770,6 +1831,14 @@ export class ManagedToolExecutor { return v3View(entry); } + measurements( + reference: ManagedToolReference, + ): ManagedToolMeasurements | null { + const entry = this.entries.get(reference.callId); + if (!entry || !sameReference(entry.reference, reference)) return null; + return entry.measurements ? structuredClone(entry.measurements) : null; + } + cancelV3(reference: ManagedToolReference): ManagedToolV3View { const entry = this.entries.get(reference.callId); if (!entry) return { state: 'unknown' }; @@ -2079,6 +2148,7 @@ export class ManagedToolExecutor { entry.input = undefined; entry.inputJson = undefined; entry.result = undefined; + entry.measurements = undefined; entry.state = 'acknowledged'; entry.lastSequence += 1; return view(entry); @@ -2242,6 +2312,13 @@ export class ManagedToolExecutor { /** The Shell's process-group leader, when this call is one with a ledger. */ let shellPgid: number | undefined; let invocationStarted = false; + let executionStartedAt: number | undefined; + let executionDurationMs: number | null = null; + let fileMeasurement: ManagedFileMeasurement | null = null; + let nativeFileSucceeded = false; + let physicalOutcome: + | ManagedToolMeasurements['executionOutcome'] + | undefined; try { // Runs start only from a fresh journal entry, which still carries its // input; only an acknowledged entry loses it, and nothing runs that. @@ -2412,68 +2489,77 @@ export class ManagedToolExecutor { `Directory '${params['directory']}' is not within any of the registered workspace directories.`, ); } - const invoke = () => { - this.assertAdmissionOpen(); - return sessionIdContext.run(sessionId, () => { - const invocation = fileInvocation ?? tool.build(params); - invocationStarted = true; - if (entry.toolName === ShellTool.Name && tools.runShell) - return tools.runShell( - params, - entry.controller.signal, - entry.captureSink, - ); - const ledger = this.options.ledger; - const trackGroup = (pid: number) => { - if (!ledger) return; - // Before any settle step of this call can run, the group is - // durable for the host's sweeps: the pid leads the group. - shellPgid = pid; - try { - ledger.addGroup({ - pgid: pid, - callId: entry.reference.callId, - startedAt: Date.now(), - }); - } catch (error) { - this.ledgerIntegrityFailed = true; - // A group that could not get into the ledger must not - // outlive the failure: stop it first, then let the call - // fail loudly instead of settling an outcome nothing - // recorded. - signalProcessGroup(pid, 'SIGKILL'); - void ledger.waitForGroupExit(pid, CLOSE_SWEEP_TIMEOUT_MS); - throw error; + const invoke = async () => { + try { + this.assertAdmissionOpen(); + return await sessionIdContext.run(sessionId, () => { + const invocation = fileInvocation ?? tool.build(params); + invocationStarted = true; + if (entry.measure) executionStartedAt = performance.now(); + if (entry.toolName === ShellTool.Name && tools.runShell) + return tools.runShell( + params, + entry.controller.signal, + entry.captureSink, + ); + const ledger = this.options.ledger; + const trackGroup = (pid: number) => { + if (!ledger) return; + // Before any settle step of this call can run, the group is + // durable for the host's sweeps: the pid leads the group. + shellPgid = pid; + try { + ledger.addGroup({ + pgid: pid, + callId: entry.reference.callId, + startedAt: Date.now(), + }); + } catch (error) { + this.ledgerIntegrityFailed = true; + // A group that could not get into the ledger must not + // outlive the failure: stop it first, then let the call + // fail loudly instead of settling an outcome nothing + // recorded. + signalProcessGroup(pid, 'SIGKILL'); + void ledger.waitForGroupExit(pid, CLOSE_SWEEP_TIMEOUT_MS); + throw error; + } + }; + if ( + entry.toolName === ShellTool.Name && + (ledger || entry.captureSink) + ) { + return (invocation as ShellToolInvocation).execute( + entry.controller.signal, + undefined, + undefined, + trackGroup, + undefined, + undefined, + entry.captureSink, + ); } - }; - if ( - entry.toolName === ShellTool.Name && - (ledger || entry.captureSink) - ) { - return (invocation as ShellToolInvocation).execute( - entry.controller.signal, - undefined, - undefined, - trackGroup, - undefined, - undefined, - entry.captureSink, + // A gate probe's command is a Shell's too: the same group + // tracking stops what it leaves behind. + if (entry.toolName === ManagedAutomationGateTool.Name && ledger) { + return ( + invocation as unknown as ManagedAutomationGateExecution + ).execute( + entry.controller.signal, + undefined, + undefined, + trackGroup, + ); + } + return invocation.execute(entry.controller.signal); + }); + } finally { + if (executionStartedAt !== undefined) + executionDurationMs = Math.max( + 0, + Math.floor(performance.now() - executionStartedAt), ); - } - // A gate probe's command is a Shell's too: the same group - // tracking stops what it leaves behind. - if (entry.toolName === ManagedAutomationGateTool.Name && ledger) { - return ( - invocation as unknown as ManagedAutomationGateExecution - ).execute( - entry.controller.signal, - undefined, - undefined, - trackGroup, - ); - } - return invocation.execute(entry.controller.signal); - }); + } }; const registeredHistory = tools.extensionAssets === true && @@ -2522,6 +2608,77 @@ export class ManagedToolExecutor { } else { result = await invoke(); } + physicalOutcome = ManagedToolExecutor.isCancelRequested(entry) + ? 'cancelled' + : result.error + ? 'error' + : 'success'; + if ( + entry.toolName === ShellTool.Name && + isShellResultDisplay(result.returnDisplay) + ) { + const outcome = result.returnDisplay.outcome; + physicalOutcome = + outcome === 'completed' + ? 'success' + : outcome === 'cancelled' + ? 'cancelled' + : 'error'; + } + nativeFileSucceeded = + !result.error && + [ReadFileTool.Name, WriteFileTool.Name, EditTool.Name].includes( + entry.toolName, + ); + if (entry.measure && nativeFileSucceeded) { + if (entry.toolName === ReadFileTool.Name) + fileMeasurement = { + operation: 'read', + status: 'measured', + modelLinesAdded: null, + modelLinesRemoved: null, + appliedLinesAdded: null, + appliedLinesRemoved: null, + }; + if ([WriteFileTool.Name, EditTool.Name].includes(entry.toolName)) { + const display = result.returnDisplay; + const diff = + display && + typeof display === 'object' && + 'originalContent' in display && + 'newContent' in display && + 'diffStat' in display + ? display + : undefined; + const applied = + diff && + !diff.originalContentTruncated && + !diff.newContentTruncated && + typeof diff.newContent === 'string' && + (typeof diff.originalContent === 'string' || + diff.originalContent === null) + ? getDiffStat( + '', + diff.originalContent ?? '', + diff.newContent, + diff.newContent, + ) + : undefined; + fileMeasurement = { + operation: entry.toolName === EditTool.Name ? 'edit' : 'write', + status: applied + ? applied.model_added_lines === 0 && + applied.model_removed_lines === 0 + ? 'unchanged' + : 'measured' + : 'unmeasured', + modelLinesAdded: diff?.diffStat?.model_added_lines ?? null, + modelLinesRemoved: diff?.diffStat?.model_removed_lines ?? null, + appliedLinesAdded: applied?.model_added_lines ?? null, + appliedLinesRemoved: applied?.model_removed_lines ?? null, + }; + } + } if (entry.toolName === GlobTool.Name) { const root = tools.directory; if (root === undefined) @@ -2606,6 +2763,17 @@ export class ManagedToolExecutor { }, }; } + if (entry.measure) + entry.measurements = { + protocol: 'managed-tool-measurements/1', + executionDurationMs, + executionOutcome: nativeFileSucceeded + ? 'success' + : !invocationStarted + ? 'not_started' + : (physicalOutcome ?? payload.executionStatus), + file: fileMeasurement, + }; if ( shellPgid !== undefined && (entry.controller.signal.aborted || this.options.sshForegroundOnly) && diff --git a/packages/cli/src/serve/managed-runtime-tool-v3-routes.ts b/packages/cli/src/serve/managed-runtime-tool-v3-routes.ts index ead24b54635..fa1442092d3 100644 --- a/packages/cli/src/serve/managed-runtime-tool-v3-routes.ts +++ b/packages/cli/src/serve/managed-runtime-tool-v3-routes.ts @@ -70,6 +70,7 @@ function reference(value: unknown): ManagedToolReference | null { export { reference as parseManagedToolReference, capture as parseManagedShellCapture, + receipt as parseManagedToolReceipt, }; function body( diff --git a/packages/cli/src/serve/managed-runtime-tool-v4-routes.test.ts b/packages/cli/src/serve/managed-runtime-tool-v4-routes.test.ts new file mode 100644 index 00000000000..f73103f79f5 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-tool-v4-routes.test.ts @@ -0,0 +1,1086 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import express from 'express'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { managedToolDigest } from '@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'; +import { resetManagedRuntimeDispatchGatesForTest } from '@qwen-code/qwen-code-core/managed-runtime/managed-runtime-dispatch-gate.js'; +import type { ManagedToolMeasurements } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-measurements.js'; +import { LocalManagedSessionAuthority } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js'; +import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js'; +import { LocalToolResultSegmentStore } from '@qwen-code/qwen-code-core/managed-runtime/local-managed-tool-result-store.js'; +import { LocalShellResultCapture } from '@qwen-code/qwen-code-core/managed-runtime/local-shell-result-capture.js'; +import type { ToolResultExpectedIdentity } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-result-store.js'; +import { + parseToolResultManifestBytes, + type ToolResultEnvelope, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-result.js'; +import type { SessionWriterLease } from '@qwen-code/qwen-code-core/services/session-writer-lease.js'; +import type { + AnyDeclarativeTool, + FileDiff, +} from '@qwen-code/qwen-code-core/tools/tools.js'; +import { + createManagedToolSet, + ManagedToolExecutor, + type ManagedToolReference, + type ManagedToolResultPayload, + type ManagedToolSet, +} from './managed-runtime-tool-executor.js'; +import { registerManagedRuntimeToolRoutes } from './managed-runtime-tool-routes.js'; +import { registerManagedRuntimeToolV3Routes } from './managed-runtime-tool-v3-routes.js'; +import { registerManagedRuntimeToolV4Routes } from './managed-runtime-tool-v4-routes.js'; +import { + startManagedRuntimeAttestationWorker, + type ManagedRuntimeAttestationWorkerHandle, +} from './managed-runtime-attestation-worker.js'; +import type { ManagedContextBoot } from './managed-context-envelope.js'; +import { computeManagedContextDigest } from './managed-workspace-binding.js'; +import { + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + MEASURED_WORKSPACE_EXECUTION_PROFILE, + WORKSPACE_CAPABILITY_DIGEST, + WORKSPACE_CONTEXT_CONFIG_REF, +} from './managed-workspace-activation.js'; + +const headers = { + authorization: 'Bearer test-measured-token', + 'cache-control': 'no-store', + 'content-type': 'application/json', + 'x-qwen-managed-lease-id': 'measured-lease', + 'x-qwen-managed-lease-epoch': '2', +}; +const identity = { + token: 'test-measured-token', + leaseId: 'measured-lease', + epoch: 2, + runtimeIncarnation: 'measured-incarnation', + capabilityDigest: MEASURED_WORKSPACE_CAPABILITY_DIGEST, +}; + +interface MeasuredView { + protocolVersion: 4; + executionProtocol: 2 | 3; + state: string; + lastSequence?: number; + result?: ManagedToolResultPayload; + measurements?: ManagedToolMeasurements | null; + resultDigest?: string; +} + +let server: Server | undefined; +let executor: ManagedToolExecutor | undefined; +let worker: ManagedRuntimeAttestationWorkerHandle | undefined; +let captureStore: LocalToolResultSegmentStore | undefined; +let captureLease: SessionWriterLease | undefined; +let workspace: string; +let origin: string; + +beforeEach(async () => { + resetManagedRuntimeDispatchGatesForTest(); + workspace = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-managed-diagnostics-v4-'), + ); + await fs.writeFile(path.join(workspace, 'fixture.txt'), 'alpha\nbeta\n'); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await worker?.close(); + await executor?.close(); + await captureStore?.close(); + await captureLease?.release(); + if (server) + await new Promise((resolve) => server!.close(() => resolve())); + await fs.rm(workspace, { recursive: true, force: true }); + executor = undefined; + server = undefined; + worker = undefined; + captureStore = undefined; + captureLease = undefined; +}); + +async function start( + capabilityDigest = MEASURED_WORKSPACE_CAPABILITY_DIGEST, + suppliedExecutor?: ManagedToolExecutor, +) { + const app = express(); + executor = + suppliedExecutor ?? + ManagedToolExecutor.forWorkspace(workspace, 'worker-tools'); + registerManagedRuntimeToolRoutes(app, identity, executor); + registerManagedRuntimeToolV3Routes(app, identity, executor); + registerManagedRuntimeToolV4Routes( + app, + { ...identity, capabilityDigest }, + executor, + ); + server = createServer(app); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', resolve)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +function reference( + callId: string, + input: Record, +): ManagedToolReference { + return { + sessionId: 'measured-session', + promptId: 'measured-turn', + callId, + argsDigest: managedToolDigest(input), + }; +} + +function body(reference: ManagedToolReference) { + return { protocolVersion: 4, executionProtocol: 2, reference }; +} + +async function post( + route: string, + value: Record, + suppliedHeaders = headers, +) { + return fetch(origin + route, { + method: 'POST', + headers: suppliedHeaders, + body: JSON.stringify(value), + }); +} + +async function execute( + callId: string, + toolName: string, + input: Record, +) { + const ref = reference(callId, input); + const reply = await post('/internal/managed-runtime/v4/execute', { + ...body(ref), + toolName, + input, + }); + expect(reply.status).toBe(200); + expect(reply.headers.get('cache-control')).toBe('no-store'); + expect(reply.headers.get('x-qwen-managed-runtime-incarnation')).toBe( + identity.runtimeIncarnation, + ); + return { ref, value: (await reply.json()) as MeasuredView }; +} + +describe('Tool v4 measured HTTP routes', () => { + it.each([false, true])( + 'freezes a real v3 Shell capture while execute waits and retains the acknowledged identity (v4 ACK before execute returns: %s)', + async (ackBeforeReturn) => { + const sessionKey = { + tenantId: 'capture-tenant', + workspaceId: 'capture-workspace', + sessionId: 'capture-session', + }; + const runtimeBaseDir = path.join(workspace, 'capture-runtime'); + const transcriptPath = path.join( + runtimeBaseDir, + 'chats', + `${sessionKey.sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + captureLease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir, + sessionId: sessionKey.sessionId, + transcriptPath, + }); + captureStore = await LocalToolResultSegmentStore.openWritable({ + sessionKey, + lease: captureLease, + }); + const resources = LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }); + const input = { + command: `"${process.execPath}" -e "require('node:fs').appendFileSync('shell-effect.txt','x'); process.stdout.write('capture-stdout'); process.stderr.write('capture-stderr')"`, + }; + const ref = reference('capture-call', input); + const capture = { + tenantId: sessionKey.tenantId, + sessionId: sessionKey.sessionId, + turnId: 'capture-turn', + executionCallId: 'capture-execution', + bindingGeneration: '1', + capturePolicy: 'complete_required' as const, + }; + const expectedIdentity: ToolResultExpectedIdentity = { + tenantId: capture.tenantId, + sessionId: capture.sessionId, + turnId: capture.turnId, + executionCallId: capture.executionCallId, + bindingGeneration: capture.bindingGeneration, + callId: ref.callId, + invocationDigest: ref.argsDigest, + captureId: 'capture-v4', + revision: 1, + }; + let preparations = 0; + const toolSet = createManagedToolSet(workspace, ref.sessionId); + const measured = new ManagedToolExecutor(async () => toolSet, { + prepare: async (request) => { + preparations++; + expect(request.reference).toEqual(ref); + expect(request.capture).toEqual(capture); + return { + identity: expectedIdentity, + sink: new LocalShellResultCapture( + captureStore!, + resources, + expectedIdentity, + ), + }; + }, + }); + await start(MEASURED_WORKSPACE_CAPABILITY_DIGEST, measured); + const request = { + protocolVersion: 4, + executionProtocol: 3, + reference: ref, + }; + let executionReady!: () => void; + const ready = new Promise((resolve) => { + executionReady = resolve; + }); + let releaseExecution!: () => void; + const released = new Promise((resolve) => { + releaseExecution = resolve; + }); + const executeV3 = measured.executeV3.bind(measured); + const delayed = vi + .spyOn(measured, 'executeV3') + .mockImplementation(async (invocation) => { + await executeV3(invocation); + executionReady(); + await released; + return measured.statusV3(invocation.reference); + }); + const first = post('/internal/managed-runtime/v4/execute', { + ...request, + toolName: 'run_shell_command', + input, + capture, + }); + try { + await ready; + const firstStatus = await post( + '/internal/managed-runtime/v4/status', + request, + ); + expect(firstStatus.status).toBe(200); + const value = (await firstStatus.json()) as MeasuredView; + const result = value.result as unknown as ToolResultEnvelope; + expect(value.state).toBe('settled'); + expect(value.lastSequence).toBe(executor!.statusV3(ref).lastSequence); + expect(value.lastSequence).toBeGreaterThan(0); + expect(result).toMatchObject({ + executionStatus: 'success', + capture: { captureStatus: 'complete', deliveryStatus: 'pending' }, + }); + expect(value.measurements?.executionOutcome).toBe('success'); + expect(value.resultDigest).toBe( + managedToolDigest( + { executionProtocol: 3, result, measurements: value.measurements }, + 2 * 1024 * 1024, + ), + ); + const manifestRef = result.capture!.manifest!; + const manifest = parseToolResultManifestBytes( + await resources.read(manifestRef), + ); + expect( + manifest.contents.find((stream) => stream.streamId === 'stdout') + ?.byteLength, + ).toBe(14); + const captured = await captureStore.readRange({ + manifestRef, + expectedIdentity, + streamId: 'stdout', + offset: 0, + length: 14, + }); + expect(captured.status).toBe('ok'); + if (captured.status !== 'ok') + throw new Error('Expected retained Shell stdout'); + expect(captured.result.toString('utf8')).toBe('capture-stdout'); + + const receipt = { + executionCallId: capture.executionCallId, + manifest: manifestRef, + deliveryStatus: 'committed', + historyRevision: 1, + }; + const delivery = await post( + '/internal/managed-runtime/v3/acknowledge', + { + protocolVersion: 3, + toolResult: 'managed-tool-result/1', + reference: ref, + receipt, + }, + ); + expect(delivery.status).toBe(200); + expect(executor!.statusV3(ref).result?.capture?.deliveryStatus).toBe( + 'committed', + ); + const acknowledged = { + protocolVersion: 4, + executionProtocol: 3, + state: 'acknowledged', + }; + const ack = { ...request, resultDigest: value.resultDigest, receipt }; + if (ackBeforeReturn) { + const reply = await post( + '/internal/managed-runtime/v4/acknowledge', + ack, + ); + expect(reply.status).toBe(200); + expect(await reply.json()).toEqual(acknowledged); + } + releaseExecution(); + const completed = await first; + expect(completed.status).toBe(200); + expect(await completed.json()).toEqual( + ackBeforeReturn ? acknowledged : value, + ); + expect(await delayed.mock.results[0]!.value).toMatchObject({ + result: { capture: { deliveryStatus: 'committed' } }, + }); + delayed.mockRestore(); + for (const route of ['status', 'cancel']) { + const frozen = await post( + `/internal/managed-runtime/v4/${route}`, + request, + ); + expect(await frozen.json()).toEqual( + ackBeforeReturn ? acknowledged : value, + ); + } + const repeated = await post('/internal/managed-runtime/v4/execute', { + ...request, + toolName: 'run_shell_command', + input, + capture, + }); + expect(await repeated.json()).toEqual( + ackBeforeReturn ? acknowledged : value, + ); + expect(preparations).toBe(1); + expect( + await fs.readFile(path.join(workspace, 'shell-effect.txt'), 'utf8'), + ).toBe('x'); + + for (let attempt = 0; attempt < 2; attempt++) { + const reply = await post( + '/internal/managed-runtime/v4/acknowledge', + ack, + ); + expect(reply.status).toBe(200); + expect(await reply.json()).toEqual(acknowledged); + } + const status = await post( + '/internal/managed-runtime/v4/status', + request, + ); + expect(await status.json()).toEqual(acknowledged); + const afterAck = await post('/internal/managed-runtime/v4/execute', { + ...request, + toolName: 'run_shell_command', + input, + capture, + }); + expect(afterAck.status).toBe(200); + expect(await afterAck.json()).toEqual(acknowledged); + for (const changed of [ + { + ...request, + toolName: 'run_shell_command', + input: { ...input, command: 'printf different' }, + capture, + }, + { ...request, toolName: 'monitor', input, capture }, + { + ...request, + toolName: 'run_shell_command', + input, + capture: { ...capture, turnId: 'other-capture-turn' }, + }, + { ...body(ref), toolName: 'run_shell_command', input }, + ]) { + const conflict = await post( + '/internal/managed-runtime/v4/execute', + changed, + ); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toMatchObject({ + code: 'managed_tool_result_conflict', + }); + } + const originalAfterConflicts = await post( + '/internal/managed-runtime/v4/execute', + { + ...request, + toolName: 'run_shell_command', + input, + capture, + }, + ); + expect(originalAfterConflicts.status).toBe(200); + expect(await originalAfterConflicts.json()).toEqual(acknowledged); + const conflicting = await post( + '/internal/managed-runtime/v4/acknowledge', + { + ...ack, + receipt: { ...receipt, historyRevision: 2 }, + }, + ); + expect(conflicting.status).toBe(409); + expect( + await fs.readFile(path.join(workspace, 'shell-effect.txt'), 'utf8'), + ).toBe('x'); + } finally { + releaseExecution(); + } + }, + ); + + it('serves v4 from the measured context Worker using its installed and activated Session', async () => { + const boot: ManagedContextBoot = { + type: 'boot', + version: 2, + managedContext: 'managed-context/1', + ...identity, + runtimeInstanceId: 'measured-runtime', + provisionRequestId: 'measured-provision', + isolationClass: 'workspace', + tenantId: 'measured-tenant', + workspaceId: 'measured-workspace', + workspaceGeneration: '1', + storageId: 'measured-storage', + mountRoot: workspace, + }; + worker = await startManagedRuntimeAttestationWorker(boot); + origin = worker.ready.url; + const binding = { + tenantId: boot.tenantId, + workspaceId: boot.workspaceId, + workspaceGeneration: boot.workspaceGeneration, + storageId: boot.storageId, + cwdRelative: '.', + contextConfigRef: WORKSPACE_CONTEXT_CONFIG_REF, + contextRevision: '1', + }; + const contextDigest = computeManagedContextDigest(binding); + const installed = await post('/internal/managed-runtime/v3/context', { + protocolVersion: 3, + managedContext: 'managed-context/1', + operationId: 'install-measured', + sessionId: 'measured-session', + binding, + contextDigest, + }); + expect(installed.status).toBe(200); + const activated = await post('/internal/managed-runtime/v3/activation', { + protocolVersion: 1, + operation: 'activate', + sessionId: 'measured-session', + contextDigest, + contextConfigRef: WORKSPACE_CONTEXT_CONFIG_REF, + profile: MEASURED_WORKSPACE_EXECUTION_PROFILE, + }); + expect(activated.status).toBe(200); + const result = await execute('worker-read', 'read_file', { + file_path: path.join(workspace, 'fixture.txt'), + }); + expect(result.value.result?.executionStatus).toBe('success'); + expect(result.value.measurements?.file?.operation).toBe('read'); + }); + + it('returns bounded native read measurements and freezes the full result digest across retries/status/cancel', async () => { + await start(); + const input = { file_path: path.join(workspace, 'fixture.txt') }; + const { ref, value } = await execute('read-1', 'read_file', input); + expect(value.state).toBe('settled'); + expect(value.lastSequence).toBe(executor!.status(ref)!.lastSequence); + expect(value.lastSequence).toBeGreaterThan(0); + expect(value.result?.executionStatus).toBe('success'); + expect(JSON.stringify(value.result?.responseParts)).toContain('alpha'); + expect(value.measurements).toMatchObject({ + protocol: 'managed-tool-measurements/1', + executionOutcome: 'success', + file: { + operation: 'read', + status: 'measured', + modelLinesAdded: null, + appliedLinesAdded: null, + }, + }); + expect(Number.isSafeInteger(value.measurements?.executionDurationMs)).toBe( + true, + ); + expect(value.measurements!.executionDurationMs).toBeGreaterThanOrEqual(0); + expect(JSON.stringify(value.measurements)).not.toContain(workspace); + expect(JSON.stringify(value.measurements)).not.toContain('alpha'); + expect(value.resultDigest).toBe( + managedToolDigest( + { + executionProtocol: 2, + result: value.result, + measurements: value.measurements, + }, + 2 * 1024 * 1024, + ), + ); + await fs.writeFile( + path.join(workspace, 'fixture.txt'), + 'replacement after first read', + ); + for (const route of ['status', 'cancel']) { + const response = await post( + `/internal/managed-runtime/v4/${route}`, + body(ref), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(value); + } + const repeated = await execute('read-1', 'read_file', input); + expect(repeated.value).toEqual(value); + }); + + it('preserves the file write effect, model/applied line measurements and the original result on execute replay', async () => { + await start(); + const file = path.join(workspace, 'created.txt'); + const input = { file_path: file, content: 'one\ntwo\n' }; + const { value } = await execute('write-1', 'write_file', input); + expect(value.result?.executionStatus).toBe('success'); + expect(await fs.readFile(file, 'utf8')).toBe('one\ntwo\n'); + expect(value.measurements?.file).toMatchObject({ + operation: 'write', + status: 'measured', + modelLinesAdded: 2, + modelLinesRemoved: 0, + appliedLinesAdded: 2, + appliedLinesRemoved: 0, + }); + await fs.writeFile(file, 'external edit after result'); + expect((await execute('write-1', 'write_file', input)).value).toEqual( + value, + ); + expect(await fs.readFile(file, 'utf8')).toBe('external edit after result'); + }); + + it('preserves a proven native write and applied diff when cancellation arrives after the physical effect', async () => { + const tools = createManagedToolSet(workspace, 'measured-session'); + const write = tools.tools.get('write_file')!; + const build = write.build.bind(write); + let executionReady!: () => void; + const ready = new Promise((resolve) => { + executionReady = resolve; + }); + let releaseExecution!: () => void; + const released = new Promise((resolve) => { + releaseExecution = resolve; + }); + let actualExecutions = 0; + vi.spyOn(write, 'build').mockImplementation((params) => { + const invocation = build(params); + const execute = invocation.execute.bind(invocation); + vi.spyOn(invocation, 'execute').mockImplementation(async (...args) => { + actualExecutions++; + const result = await execute(...args); + executionReady(); + await released; + return result; + }); + return invocation; + }); + await start( + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + new ManagedToolExecutor(async () => tools), + ); + const file = path.join(workspace, 'cancel-after-write.txt'); + const input = { file_path: file, content: 'one\ntwo\n' }; + const ref = reference('cancel-after-write', input); + const first = post('/internal/managed-runtime/v4/execute', { + ...body(ref), + toolName: 'write_file', + input, + }); + try { + await ready; + expect(await fs.readFile(file, 'utf8')).toBe('one\ntwo\n'); + const cancellation = await post( + '/internal/managed-runtime/v4/cancel', + body(ref), + ); + expect(cancellation.status).toBe(200); + expect(await cancellation.json()).toMatchObject({ + state: 'cancel_requested', + }); + releaseExecution(); + const completed = await first; + expect(completed.status).toBe(200); + const value = (await completed.json()) as MeasuredView; + expect(value.result?.executionStatus).toBe('cancelled'); + expect(value.measurements).toMatchObject({ + executionOutcome: 'success', + file: { + operation: 'write', + status: 'measured', + modelLinesAdded: 2, + modelLinesRemoved: 0, + appliedLinesAdded: 2, + appliedLinesRemoved: 0, + }, + }); + expect(actualExecutions).toBe(1); + await fs.writeFile(file, 'external edit after cancelled delivery'); + expect( + (await execute('cancel-after-write', 'write_file', input)).value, + ).toEqual(value); + expect(actualExecutions).toBe(1); + expect(await fs.readFile(file, 'utf8')).toBe( + 'external edit after cancelled delivery', + ); + } finally { + releaseExecution(); + } + }); + + it('preserves a proven Shell completion when cancellation arrives before result delivery', async () => { + const tools = createManagedToolSet(workspace, 'measured-session'); + const write = tools.tools.get('run_shell_command')!; + const build = write.build.bind(write); + let executionReady!: () => void; + const ready = new Promise((resolve) => { + executionReady = resolve; + }); + let releaseExecution!: () => void; + const released = new Promise((resolve) => { + releaseExecution = resolve; + }); + let actualExecutions = 0; + vi.spyOn(write, 'build').mockImplementation((params) => { + const invocation = build(params); + const execute = invocation.execute.bind(invocation); + vi.spyOn(invocation, 'execute').mockImplementation(async (...args) => { + actualExecutions++; + const result = await execute(...args); + executionReady(); + await released; + return result; + }); + return invocation; + }); + await start( + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + new ManagedToolExecutor(async () => tools), + ); + const file = path.join(workspace, 'cancel-after-shell.txt'); + const input = { + command: `${JSON.stringify(process.execPath)} -e "require('node:fs').writeFileSync('cancel-after-shell.txt', 'one\\ntwo\\n')"`, + }; + const ref = reference('cancel-after-shell', input); + const first = post('/internal/managed-runtime/v4/execute', { + ...body(ref), + toolName: 'run_shell_command', + input, + }); + try { + await ready; + expect(await fs.readFile(file, 'utf8')).toBe('one\ntwo\n'); + const cancellation = await post( + '/internal/managed-runtime/v4/cancel', + body(ref), + ); + expect(cancellation.status).toBe(200); + expect(await cancellation.json()).toMatchObject({ + state: 'cancel_requested', + }); + releaseExecution(); + const completed = await first; + expect(completed.status).toBe(200); + const value = (await completed.json()) as MeasuredView; + expect(value.result?.executionStatus).toBe('cancelled'); + expect(value.measurements).toMatchObject({ + executionOutcome: 'success', + file: null, + }); + expect(actualExecutions).toBe(1); + await fs.writeFile(file, 'external edit after cancelled delivery'); + expect( + (await execute('cancel-after-shell', 'run_shell_command', input)).value, + ).toEqual(value); + expect(actualExecutions).toBe(1); + expect(await fs.readFile(file, 'utf8')).toBe( + 'external edit after cancelled delivery', + ); + } finally { + releaseExecution(); + } + }); + + it('records not_started measurement for a native build failure while retaining the legacy error result', async () => { + const tools = createManagedToolSet(workspace, 'measured-session'); + const build = vi + .spyOn(tools.tools.get('write_file')!, 'build') + .mockImplementation(() => { + throw new Error('test validation failed before invocation'); + }); + await start( + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + new ManagedToolExecutor(async () => tools), + ); + const file = path.join(workspace, 'must-not-be-written.txt'); + const { value } = await execute('build-failed', 'write_file', { + file_path: file, + content: 'unwritten', + }); + expect(value.result?.executionStatus).toBe('error'); + expect(value.measurements).toEqual({ + protocol: 'managed-tool-measurements/1', + executionOutcome: 'not_started', + executionDurationMs: null, + file: null, + }); + expect(build).toHaveBeenCalledTimes(1); + await expect(fs.stat(file)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps a native write and diff when output conversion fails after execution', async () => { + const tools = createManagedToolSet(workspace, 'measured-session'); + const write = tools.tools.get('write_file')!; + const build = write.build.bind(write); + vi.spyOn(write, 'build').mockImplementation((params) => { + const invocation = build(params); + const invoke = invocation.execute.bind(invocation); + vi.spyOn(invocation, 'execute').mockImplementation(async (...args) => { + const result = await invoke(...args); + return { + ...result, + get llmContent(): never { + throw new Error('output conversion failed'); + }, + }; + }); + return invocation; + }); + await start( + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + new ManagedToolExecutor(async () => tools), + ); + const file = path.join(workspace, 'write-before-output-error.txt'); + const { value } = await execute('output-error', 'write_file', { + file_path: file, + content: 'one\ntwo\n', + }); + expect(await fs.readFile(file, 'utf8')).toBe('one\ntwo\n'); + expect(value.result?.executionStatus).toBe('error'); + expect(value.measurements).toMatchObject({ + executionOutcome: 'success', + file: { operation: 'write', modelLinesAdded: 2, appliedLinesAdded: 2 }, + }); + }); + + it.each(['originalContentTruncated', 'newContentTruncated'] as const)( + 'does not calculate applied lines from a FileDiff whose %s preview is incomplete', + async (truncated) => { + const display: FileDiff = { + fileDiff: 'partial preview', + fileName: 'fixture.txt', + originalContent: 'alpha\n', + newContent: 'beta\n', + truncatedForSession: true, + [truncated]: true, + diffStat: { + model_added_lines: 13, + model_removed_lines: 7, + model_added_chars: 0, + model_removed_chars: 0, + user_added_lines: 0, + user_removed_lines: 0, + user_added_chars: 0, + user_removed_chars: 0, + }, + }; + const tools: ManagedToolSet = { + sessionId: 'measured-session', + admitsDirectory: () => true, + tools: new Map([ + [ + 'edit', + { + validateToolParams: () => null, + build: () => ({ + execute: async () => ({ + llmContent: 'edited', + returnDisplay: display, + }), + }), + } as unknown as AnyDeclarativeTool, + ], + ]), + }; + await start( + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + new ManagedToolExecutor(async () => tools), + ); + const { value } = await execute('truncated-edit', 'edit', { + file_path: 'fixture.txt', + old_string: 'alpha', + new_string: 'beta', + }); + expect(value.result?.executionStatus).toBe('success'); + expect(value.measurements?.file).toEqual({ + operation: 'edit', + status: 'unmeasured', + modelLinesAdded: 13, + modelLinesRemoved: 7, + appliedLinesAdded: null, + appliedLinesRemoved: null, + }); + }, + ); + + it('can measure full old/new file contents when only the textual patch preview is truncated', async () => { + const display: FileDiff = { + fileDiff: 'truncated patch', + fileName: 'fixture.txt', + originalContent: 'alpha\n', + newContent: 'beta\n', + truncatedForSession: true, + fileDiffTruncated: true, + originalContentTruncated: false, + newContentTruncated: false, + diffStat: { + model_added_lines: 13, + model_removed_lines: 7, + model_added_chars: 0, + model_removed_chars: 0, + user_added_lines: 0, + user_removed_lines: 0, + user_added_chars: 0, + user_removed_chars: 0, + }, + }; + const tools: ManagedToolSet = { + sessionId: 'measured-session', + admitsDirectory: () => true, + tools: new Map([ + [ + 'edit', + { + validateToolParams: () => null, + build: () => ({ + execute: async () => ({ + llmContent: 'edited', + returnDisplay: display, + }), + }), + } as unknown as AnyDeclarativeTool, + ], + ]), + }; + await start( + MEASURED_WORKSPACE_CAPABILITY_DIGEST, + new ManagedToolExecutor(async () => tools), + ); + const { value } = await execute('patch-truncated-edit', 'edit', { + file_path: 'fixture.txt', + old_string: 'alpha', + new_string: 'beta', + }); + expect(value.measurements?.file).toEqual({ + operation: 'edit', + status: 'measured', + modelLinesAdded: 13, + modelLinesRemoved: 7, + appliedLinesAdded: 1, + appliedLinesRemoved: 1, + }); + }); + + it('rejects a wrong full-result ACK and makes the original ACK idempotent after payload reclamation', async () => { + await start(); + const { ref, value } = await execute('ack-read', 'read_file', { + file_path: path.join(workspace, 'fixture.txt'), + }); + const wrong = await post('/internal/managed-runtime/v4/acknowledge', { + ...body(ref), + resultDigest: '0'.repeat(64), + }); + expect(wrong.status).toBe(409); + const stillPresent = await post( + '/internal/managed-runtime/v4/status', + body(ref), + ); + expect(await stillPresent.json()).toEqual(value); + const ack = { ...body(ref), resultDigest: value.resultDigest }; + for (let attempt = 0; attempt < 2; attempt++) { + const response = await post( + '/internal/managed-runtime/v4/acknowledge', + ack, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + protocolVersion: 4, + executionProtocol: 2, + state: 'acknowledged', + }); + } + const status = await post('/internal/managed-runtime/v4/status', body(ref)); + expect(await status.json()).toEqual({ + protocolVersion: 4, + executionProtocol: 2, + state: 'acknowledged', + }); + const conflict = await post('/internal/managed-runtime/v4/acknowledge', { + ...ack, + resultDigest: '0'.repeat(64), + }); + expect(conflict.status).toBe(409); + }); + + it('retains the closed legacy v2 result and v3 status envelope on a new executor', async () => { + await start(); + const input = { file_path: path.join(workspace, 'fixture.txt') }; + const ref = reference('legacy-read', input); + const response = await post('/internal/managed-runtime/v2/execute', { + protocolVersion: 2, + reference: ref, + toolName: 'read_file', + input, + }); + expect(response.status).toBe(200); + const value = (await response.json()) as Record; + expect(value).toMatchObject({ + protocolVersion: 2, + state: 'settled', + result: { executionStatus: 'success' }, + }); + expect(value).not.toHaveProperty('measurements'); + expect(value).not.toHaveProperty('resultDigest'); + expect(value['result']).not.toHaveProperty('measurements'); + const status = await post('/internal/managed-runtime/v3/status', { + protocolVersion: 3, + toolResult: 'managed-tool-result/1', + reference: reference('unknown-v3', input), + }); + expect(status.status).toBe(200); + const v3 = (await status.json()) as Record; + expect(v3).toMatchObject({ protocolVersion: 3, state: 'unknown' }); + expect(v3).not.toHaveProperty('measurements'); + expect(v3).not.toHaveProperty('resultDigest'); + }); + + it('does not register v4 for the original Workspace capability', async () => { + await start(WORKSPACE_CAPABILITY_DIGEST); + const input = { file_path: path.join(workspace, 'fixture.txt') }; + const response = await post('/internal/managed-runtime/v4/execute', { + ...body(reference('old-profile', input)), + toolName: 'read_file', + input, + }); + expect(response.status).toBe(404); + expect(executor!.measurements(reference('old-profile', input))).toBeNull(); + }); + + it('rejects wrong credentials, lease identity and unknown fields before dispatch', async () => { + await start(); + const file = path.join(workspace, 'must-not-exist.txt'); + const input = { file_path: file, content: 'forbidden' }; + const value = { + ...body(reference('blocked-write', input)), + toolName: 'write_file', + input, + }; + const unauthenticated = await post( + '/internal/managed-runtime/v4/execute', + value, + { ...headers, authorization: 'Bearer wrong' }, + ); + expect(unauthenticated.status).toBe(401); + expect( + unauthenticated.headers.get('x-qwen-managed-runtime-incarnation'), + ).toBeNull(); + const wrongLease = await post( + '/internal/managed-runtime/v4/execute', + value, + { ...headers, 'x-qwen-managed-lease-id': 'other-lease' }, + ); + expect(wrongLease.status).toBe(409); + const unknown = await post('/internal/managed-runtime/v4/execute', { + ...value, + arbitraryMeasurement: 1, + }); + expect(unknown.status).toBe(400); + await expect(fs.stat(file)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('treats a reference from another Session as unknown without exposing or acknowledging its result', async () => { + await start(); + const { ref, value } = await execute('owned-read', 'read_file', { + file_path: path.join(workspace, 'fixture.txt'), + }); + const response = await post( + '/internal/managed-runtime/v4/status', + body({ ...ref, sessionId: 'another-session' }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + protocolVersion: 4, + executionProtocol: 2, + state: 'unknown', + }); + const ack = await post('/internal/managed-runtime/v4/acknowledge', { + ...body({ ...ref, sessionId: 'another-session' }), + resultDigest: value.resultDigest, + }); + expect(await ack.json()).toEqual({ + protocolVersion: 4, + executionProtocol: 2, + state: 'unknown', + }); + const original = await post( + '/internal/managed-runtime/v4/status', + body(ref), + ); + expect(await original.json()).toEqual(value); + }); + + it('returns a protocol conflict when redispatch attempts to reuse an existing invocation under another Session', async () => { + await start(); + const input = { file_path: path.join(workspace, 'fixture.txt') }; + const { ref, value } = await execute( + 'conflicting-read', + 'read_file', + input, + ); + const response = await post('/internal/managed-runtime/v4/execute', { + ...body({ ...ref, sessionId: 'another-session' }), + toolName: 'read_file', + input, + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: 'managed_runtime_identity_conflict', + }); + const original = await post( + '/internal/managed-runtime/v4/status', + body(ref), + ); + expect(await original.json()).toEqual(value); + }); +}); diff --git a/packages/cli/src/serve/managed-runtime-tool-v4-routes.ts b/packages/cli/src/serve/managed-runtime-tool-v4-routes.ts new file mode 100644 index 00000000000..3c04ad14ee6 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-tool-v4-routes.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, Response, NextFunction } from 'express'; +import { managedToolDigest } from '@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'; +import { + authorizeManagedRuntime, + handleManagedRuntimeJsonError, + managedRuntimeJsonBody, + managedRuntimeNoStore, + nameManagedRuntimeIncarnation, + type ManagedRuntimeAttestationIdentity, + type ManagedRuntimeRequestIdentity, +} from './managed-runtime-attestation-contract.js'; +import { + parseManagedToolReference, + parseManagedShellCapture, + parseManagedToolReceipt, +} from './managed-runtime-tool-v3-routes.js'; +import { MEASURED_WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js'; +import type { + ManagedToolExecutor, + ManagedToolInvocationView, + ManagedToolV3View, +} from './managed-runtime-tool-executor.js'; +import { + ManagedToolConflictError, + ManagedToolInvalidError, + ManagedToolUnavailableError, +} from './managed-runtime-tool-executor.js'; + +export const MANAGED_RUNTIME_TOOL_V4_ROUTES = ( + ['execute', 'status', 'cancel', 'acknowledge'] as const +).map((key) => ({ + key: 'measured-' + key, + method: 'POST' as const, + path: '/internal/managed-runtime/v4/' + key, + protocolVersion: 4, + requestBodyLimitBytes: key === 'execute' ? 256 * 1024 : 16 * 1024, + responseBodyLimitBytes: 1024 * 1024 + 4096, + cacheControl: 'no-store' as const, +})); + +function object(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function measuredToolView( + executionProtocol: 2 | 3, + input: ManagedToolInvocationView | ManagedToolV3View | null, + measurements: ReturnType, +): Record { + const view = input ?? { state: 'unknown' as const }; + const result: Record = { + protocolVersion: 4, + executionProtocol, + ...view, + }; + if (view.state === 'settled' && view.result) { + result['measurements'] = measurements; + result['resultDigest'] = managedToolDigest( + { executionProtocol, result: view.result, measurements }, + 2 * 1024 * 1024, + ); + } + return result; +} + +export function registerManagedRuntimeToolV4Routes( + app: Application, + identity: ManagedRuntimeRequestIdentity & + Pick< + ManagedRuntimeAttestationIdentity, + 'runtimeIncarnation' | 'capabilityDigest' + >, + executor: ManagedToolExecutor, +): void { + if (identity.capabilityDigest !== MEASURED_WORKSPACE_CAPABILITY_DIGEST) + return; + const frozen = new Map< + string, + { + mode: 2 | 3; + digest: string; + value?: Record; + receiptDigest?: string; + } + >(); + const requests = new Map(); + for (const route of MANAGED_RUNTIME_TOOL_V4_ROUTES) { + app.post( + route.path, + managedRuntimeNoStore, + authorizeManagedRuntime(identity), + nameManagedRuntimeIncarnation(identity), + managedRuntimeJsonBody(route.requestBodyLimitBytes), + async (req: Request, res: Response, next: NextFunction) => { + const body = object(req.body); + const reference = parseManagedToolReference(body?.['reference']); + const mode = body?.['executionProtocol']; + const execute = route.key === 'measured-execute'; + const acknowledge = route.key === 'measured-acknowledge'; + const allowed = [ + 'protocolVersion', + 'executionProtocol', + 'reference', + ...(execute + ? ['toolName', 'input', ...(mode === 3 ? ['capture'] : [])] + : []), + ...(acknowledge + ? ['resultDigest', ...(mode === 3 ? ['receipt'] : [])] + : []), + ...(route.key === 'measured-status' ? ['afterSequence'] : []), + ]; + if ( + !body || + !reference || + body['protocolVersion'] !== 4 || + (mode !== 2 && mode !== 3) || + Object.keys(body).some((key) => !allowed.includes(key)) || + (body['afterSequence'] !== undefined && + (!Number.isSafeInteger(body['afterSequence']) || + Number(body['afterSequence']) < 0)) + ) { + res.status(400).json({ code: 'managed_runtime_measurement_invalid' }); + return; + } + try { + const identityKey = managedToolDigest(reference); + const saved = frozen.get(identityKey); + if (saved && saved.mode !== mode) { + res.status(409).json({ code: 'managed_tool_result_conflict' }); + return; + } + if (execute) { + if ( + !object(body['input']) || + typeof body['toolName'] !== 'string' || + !body['toolName'].length || + (mode === 3 && !parseManagedShellCapture(body['capture'])) + ) { + res + .status(400) + .json({ code: 'managed_runtime_measurement_invalid' }); + return; + } + const requestDigest = managedToolDigest({ + executionProtocol: mode, + toolName: body['toolName'], + input: body['input'], + capture: body['capture'] ?? null, + }); + const original = requests.get(identityKey); + if (original && original !== requestDigest) { + res.status(409).json({ code: 'managed_tool_result_conflict' }); + return; + } + requests.set(identityKey, requestDigest); + } + if (saved && !saved.value) { + if ( + acknowledge && + (body['resultDigest'] !== saved.digest || + managedToolDigest(body['receipt'] ?? null) !== + saved.receiptDigest) + ) { + res.status(409).json({ code: 'managed_tool_result_conflict' }); + return; + } + res.json({ + protocolVersion: 4, + executionProtocol: mode, + state: 'acknowledged', + }); + return; + } + let view: ManagedToolInvocationView | ManagedToolV3View | null; + if (execute) { + const input = object(body['input']); + const toolName = body['toolName']; + const capture = + mode === 3 + ? parseManagedShellCapture(body['capture']) + : undefined; + if ( + !input || + typeof toolName !== 'string' || + !toolName.length || + (mode === 3 && !capture) + ) { + res + .status(400) + .json({ code: 'managed_runtime_measurement_invalid' }); + return; + } + if (mode === 2) { + await executor.execute(reference, toolName, input, true); + view = await executor.status(reference); + } else { + view = await executor.executeV3({ + reference, + measure: true, + toolName, + input, + capture: { + ...capture!, + ...(input['is_background'] === true + ? { background: true as const } + : {}), + ...(toolName === 'monitor' + ? { monitoring: true as const } + : {}), + }, + }); + } + } else if (acknowledge) { + view = + mode === 2 + ? await executor.status(reference) + : executor.statusV3(reference); + if ( + !view || + view.state === 'unknown' || + view.state === 'acknowledged' + ) { + res.json({ + protocolVersion: 4, + executionProtocol: mode, + state: view?.state ?? 'unknown', + }); + return; + } + const measured = + saved?.value ?? + measuredToolView(mode, view, executor.measurements(reference)); + if ( + view.state !== 'settled' || + body['resultDigest'] !== measured['resultDigest'] + ) { + res.status(409).json({ code: 'managed_tool_result_conflict' }); + return; + } + if (mode === 2) view = executor.acknowledge(reference); + else { + const receipt = parseManagedToolReceipt(body['receipt']); + if (!receipt) { + res + .status(400) + .json({ code: 'managed_runtime_measurement_invalid' }); + return; + } + view = await executor.acknowledgeV3(reference, receipt); + } + frozen.set(identityKey, { + mode, + digest: String(measured['resultDigest']), + receiptDigest: managedToolDigest(body['receipt'] ?? null), + }); + res.json({ + protocolVersion: 4, + executionProtocol: mode, + state: 'acknowledged', + }); + return; + } else { + view = + route.key === 'measured-cancel' + ? mode === 2 + ? await executor.cancel(reference) + : executor.cancelV3(reference) + : mode === 2 + ? await executor.status(reference) + : executor.statusV3(reference); + } + const current = frozen.get(identityKey); + if (current && !current.value) { + res.json({ + protocolVersion: 4, + executionProtocol: mode, + state: 'acknowledged', + }); + return; + } + const measured = + current?.value ?? + measuredToolView(mode, view, executor.measurements(reference)); + if (measured['state'] === 'settled' && !current) + frozen.set(identityKey, { + mode, + digest: String(measured['resultDigest']), + value: structuredClone(measured), + }); + res.json(measured); + } catch (error) { + if (error instanceof ManagedToolConflictError) { + res.status(409).json({ code: error.code, error: error.message }); + return; + } + if (error instanceof ManagedToolInvalidError) { + res.status(400).json({ + code: 'managed_runtime_measurement_invalid', + error: error.message, + }); + return; + } + if (error instanceof ManagedToolUnavailableError) { + res.status(409).json({ code: error.code, error: error.message }); + return; + } + next(error); + } + }, + handleManagedRuntimeJsonError, + ); + } +} diff --git a/packages/cli/src/serve/managed-workspace-activation.ts b/packages/cli/src/serve/managed-workspace-activation.ts index 9deeb99bef4..984a7e27382 100644 --- a/packages/cli/src/serve/managed-workspace-activation.ts +++ b/packages/cli/src/serve/managed-workspace-activation.ts @@ -27,6 +27,17 @@ export const WORKSPACE_CONTEXT_CONFIG_REF = 'sha256:5fa15183dcfe582ce54c28bf25aff62801d19038a9ec649e9bfc67b06f2a389f'; export const WORKSPACE_CAPABILITY_DIGEST = 'sha256:7df3981fa14b397fe09f6ba84ed0c096c71902394da667bf47f3b315866e7f91'; +export const MEASURED_WORKSPACE_CAPABILITY_DIGEST = + 'sha256:b2ab403f278282eb8f2ca06c926be96bd3d85877714f64dc926eab535f544580'; +export const MEASURED_WORKSPACE_EXECUTION_PROFILE = + 'managed-workspace-execution/2'; + +export function isWorkspaceToolCapability(digest: string): boolean { + return ( + digest === WORKSPACE_CAPABILITY_DIGEST || + digest === MEASURED_WORKSPACE_CAPABILITY_DIGEST + ); +} export const RESIDENT_WORKSPACE_PROFILE = 'resident-workspace-files/1'; export const RESIDENT_WORKSPACE_CAPABILITY_DIGEST = 'sha256:2cbf6f3825230848a65a546ec70714fbb2a53d1862a6caba73ae4cebf307b4ad'; @@ -92,6 +103,7 @@ export class WorkspaceActivations { request['profile'] !== RESIDENT_WORKSPACE_PROFILE : ![ WORKSPACE_CAPABILITY_DIGEST, + MEASURED_WORKSPACE_CAPABILITY_DIGEST, ...(boot.managedExtensions ? [ MANAGED_EXTENSION_LINUX_CAPABILITY_DIGEST, @@ -99,7 +111,10 @@ export class WorkspaceActivations { ] : []), ].includes(boot.capabilityDigest) || - request['profile'] !== WORKSPACE_EXECUTION_PROFILE) || + request['profile'] !== + (boot.capabilityDigest === MEASURED_WORKSPACE_CAPABILITY_DIGEST + ? MEASURED_WORKSPACE_EXECUTION_PROFILE + : WORKSPACE_EXECUTION_PROFILE)) || binding === undefined || binding.contextConfigRef !== WORKSPACE_CONTEXT_CONFIG_REF || request['contextConfigRef'] !== binding.contextConfigRef || diff --git a/packages/cli/src/serve/workspace-recovery-session.ts b/packages/cli/src/serve/workspace-recovery-session.ts index 8c13f7102ca..d44dbdecdba 100644 --- a/packages/cli/src/serve/workspace-recovery-session.ts +++ b/packages/cli/src/serve/workspace-recovery-session.ts @@ -5,6 +5,10 @@ */ import path from 'node:path'; +import { + MANAGED_TOOL_MEASUREMENTS_KIND, + parseManagedToolMeasurementEvidence, +} from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-measurements.js'; import { EXECUTION_DIAGNOSTICS_KIND, parseManagedExecutionEvidence, @@ -185,6 +189,7 @@ const DOMAINS = [ ]; const RESOURCE_KINDS = new Set([ EXECUTION_DIAGNOSTICS_KIND, + MANAGED_TOOL_MEASUREMENTS_KIND, 'managed-definition', 'managed-agent-instructions', 'managed-agent-skills', @@ -350,7 +355,7 @@ export async function verifyRecoverySession( typeof retiredHead.workspaceId === 'string' && retiredHead.workspaceId.length > 0 && retiredHead.state === 'DELETED' && - [1, 2, 3, 4].includes(retiredHead.storageVersion) && + [1, 2, 3, 4, 5].includes(retiredHead.storageVersion) && retiredHead.writerId === null && retiredHead.writerLeaseUntil === null && retiredHead.latestCheckpointResourceId === null && @@ -403,7 +408,7 @@ export async function verifyRecoverySession( !head || (head.sessionId === source.sessionId && head.tenantId === source.binding.tenantId && - [1, 2, 3, 4].includes(head.storageVersion) && + [1, 2, 3, 4, 5].includes(head.storageVersion) && head.journalRevision >= 1 && head.compactedThroughRevision === 0 && head.recoveryStatus === 'READY'), @@ -1076,6 +1081,12 @@ export async function verifyRecoverySession( } else if (ref.kind === 'managed-checkpoint-history') { for (const nested of collectNestedResourceRefs(ref, bytes)) await io.enqueue(nested); + } else if (ref.kind === MANAGED_TOOL_MEASUREMENTS_KIND) { + requireValue( + ref.schemaVersion === 1 && (head?.storageVersion ?? 0) >= 5, + 'unsupported tool measurements', + ); + parseManagedToolMeasurementEvidence(bytes); } else if (ref.kind === EXECUTION_DIAGNOSTICS_KIND) { if (ref.schemaVersion !== 1) throw new Error( diff --git a/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.fixtures.json index 85ecfc301f1..e424a448eb4 100644 --- a/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.fixtures.json +++ b/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.fixtures.json @@ -11,6 +11,7 @@ "tool.intent", "action.changed", "tool.receipt", + "tool.measured", "checkpoint.committed", "context.compacted", "cancel.requested", @@ -182,6 +183,24 @@ } } }, + { + "id": "kind-tool-measured", + "valid": true, + "envelope": { + "v": 1, + "sessionId": "session-1", + "tenantId": "tenant-1", + "workspaceId": "workspace-1", + "stream": "authoritative_journal", + "sequence": 108, + "eventId": "evt-000108", + "kind": "tool.measured", + "occurredAt": 1760000000008, + "payloadRef": { + "digest": "631f42ee57f8e39d9491a76ba9477d32a11f8c2b8a3a4ff3c21b30760c600000" + } + } + }, { "id": "kind-tool-receipt", "valid": true, diff --git a/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.schema.json b/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.schema.json index 34115dda598..217923ab632 100644 --- a/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.schema.json +++ b/packages/core/src/managed-runtime/contracts/managed-event-envelope-v1.schema.json @@ -38,6 +38,7 @@ "tool.intent", "action.changed", "tool.receipt", + "tool.measured", "checkpoint.committed", "context.compacted", "cancel.requested", @@ -206,6 +207,7 @@ "tool.intent", "action.changed", "tool.receipt", + "tool.measured", "checkpoint.committed", "context.compacted", "cancel.requested", diff --git a/packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json index 13f612b8d6e..b03a8ab70db 100644 --- a/packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json +++ b/packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json @@ -26,6 +26,7 @@ "tool.intent", "action.changed", "tool.receipt", + "tool.measured", "checkpoint.committed", "context.compacted", "cancel.requested", diff --git a/packages/core/src/managed-runtime/contracts/managed-tool-measurements-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-tool-measurements-v1.fixtures.json new file mode 100644 index 00000000000..70b348a6647 --- /dev/null +++ b/packages/core/src/managed-runtime/contracts/managed-tool-measurements-v1.fixtures.json @@ -0,0 +1,307 @@ +{ + "cases": [ + { + "id": "model-and-applied", + "valid": true, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "zero-duration", + "valid": true, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": null + } + }, + { + "id": "maximum-safe-integer", + "valid": true, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 9007199254740991, + "executionOutcome": "success", + "file": null + } + }, + { + "id": "integral-decimal", + "valid": true, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 1.0, + "executionOutcome": "success", + "file": null + } + }, + { + "id": "not-started", + "valid": true, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": null, + "executionOutcome": "not_started", + "file": null + } + }, + { + "id": "unknown", + "valid": true, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": null, + "executionOutcome": "unknown", + "file": null + } + }, + { + "id": "read", + "valid": true, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "read", + "status": "measured", + "modelLinesAdded": null, + "modelLinesRemoved": null, + "appliedLinesAdded": null, + "appliedLinesRemoved": null + } + } + }, + { + "id": "negative", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": -1, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "fractional", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 1.5, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "unsafe", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 9007199254740992, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "opaque", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + }, + "arguments": { + "path": "private" + } + } + }, + { + "id": "wrong-protocol", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/2", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "file-without-success", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "error", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "read-with-lines", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "read", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "unchanged-with-delta", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "unchanged", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "unmeasured-with-applied", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "unmeasured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + { + "id": "missing-pair", + "valid": false, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": null, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + } + ], + "evidence": { + "schemaVersion": 1, + "invocationId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "executionCallId": "调用-1", + "modelProposal": "original", + "resultDigest": "86e7d29ce46bee137e680bc1852cb5c6b00df8fea57eb971804e92746d213a19", + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + "wire": { + "executionProtocol": 2, + "result": { + "executionStatus": "success", + "responseParts": [ + { + "type": "text", + "text": "完成 ✓" + } + ] + }, + "measurements": { + "protocol": "managed-tool-measurements/1", + "executionDurationMs": 0, + "executionOutcome": "success", + "file": { + "operation": "edit", + "status": "measured", + "modelLinesAdded": 3, + "modelLinesRemoved": 1, + "appliedLinesAdded": 2, + "appliedLinesRemoved": 1 + } + } + }, + "resultDigest": "86e7d29ce46bee137e680bc1852cb5c6b00df8fea57eb971804e92746d213a19" +} diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts index dcdf425bc5c..41569a276eb 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.test.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -2023,7 +2023,7 @@ describe('HTTP Managed Session store', () => { expect(headers.get('X-Qwen-Tenant-Id')).toBe(SESSION_KEY.tenantId); expect(headers.get('X-Qwen-Managed-Writer-Token')).toBe(TOKEN_A); expect(headers.get('X-Qwen-Managed-Max-Readable-Storage-Version')).toBe( - '4', + '5', ); expect(init?.method).toBe('POST'); expect(JSON.parse(String(init?.body))).toEqual(request); diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts index 6a12567c710..75c1d26fc11 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + MANAGED_TOOL_MEASUREMENTS_KIND, + parseManagedToolMeasurementEvidence, +} from './managed-tool-measurements.js'; import { EXECUTION_DIAGNOSTICS_KIND, parseManagedExecutionEvidence, @@ -317,7 +321,7 @@ export function readOnlyManagedSessionSnapshot(value: unknown) { ); const head = parseRestoreHead(snapshot['head']); if ( - ![1, 2, 3, 4].includes(head.storageVersion) || + ![1, 2, 3, 4, 5].includes(head.storageVersion) || !['ACTIVE', 'SEALED'].includes(head.state) || head.recoveryStatus !== 'READY' || head.compactedThroughRevision !== 0 @@ -471,6 +475,13 @@ export function readOnlyManagedSessionSnapshot(value: unknown) { if (resources.size !== references.size) { throw corrupt('snapshot resource enumeration conflicts.'); } + if ( + head.storageVersion < 5 && + [...resources.values()].some( + ({ ref }) => ref.kind === MANAGED_TOOL_MEASUREMENTS_KIND, + ) + ) + throw corrupt('Tool measurements require storage version 5.'); const executionPresent = [...resources.values()].some( ({ ref }) => ref.kind === EXECUTION_DIAGNOSTICS_KIND, ); @@ -1206,6 +1217,13 @@ class ManagedSessionStoreHttpClient { this.sessionKey, ); requireStoredTransactionMatches(transaction, descriptor); + if ( + head.storageVersion < 5 && + descriptor.refs.some( + (ref) => ref.kind === MANAGED_TOOL_MEASUREMENTS_KIND, + ) + ) + throw corrupt('Tool measurements require storage version 5.'); if ( head.storageVersion < 4 && descriptor.refs.some((ref) => ref.kind === EXECUTION_DIAGNOSTICS_KIND) @@ -1442,7 +1460,7 @@ class ManagedSessionStoreHttpClient { raw['sessionId'] !== this.sessionKey.sessionId || snapshot.snapshotId.length === 0 || Buffer.byteLength(snapshot.snapshotId, 'utf8') > 512 || - ![1, 2, 3, 4].includes(storageVersion) || + ![1, 2, 3, 4, 5].includes(storageVersion) || storageVersion > head.storageVersion || snapshot.coveredJournalRevision < 1 || snapshot.coveredJournalRevision > head.journalRevision || @@ -1554,6 +1572,11 @@ class ManagedSessionStoreHttpClient { const declared = append(transaction).refs; for (const resource of added) { const body = await this.readResource(resource); + if ( + storageVersion < 5 && + resource.kind === MANAGED_TOOL_MEASUREMENTS_KIND + ) + throw corrupt('Tool measurements require storage version 5.'); if ( storageVersion < 4 && resource.kind === EXECUTION_DIAGNOSTICS_KIND @@ -1978,6 +2001,13 @@ class ManagedSessionStoreHttpClient { grant.supportedStorageVersion < 4 ) throw corrupt('Execution diagnostics require storage version 4.'); + if ( + descriptor.refs.some( + (ref) => ref.kind === MANAGED_TOOL_MEASUREMENTS_KIND, + ) && + grant.supportedStorageVersion < 5 + ) + throw corrupt('Tool measurements require storage version 5.'); const publicationIds = new Set( descriptor.refs.flatMap((ref) => { const id = this.publicationAdmissions.get(ref.resourceId); @@ -2255,7 +2285,7 @@ class ManagedSessionStoreHttpClient { method, headers: { Accept: 'application/json', - 'X-Qwen-Managed-Max-Readable-Storage-Version': '4', + 'X-Qwen-Managed-Max-Readable-Storage-Version': '5', ...(readOnly ? {} : { 'Content-Type': 'application/json' }), [HTTP_MANAGED_SESSION_STORE_CONTRACT.tenantHeader]: this.sessionKey.tenantId, @@ -2797,7 +2827,7 @@ class ManagedSessionStoreHttpClient { const grant = this.requireGrant(); if ( head.state !== 'ACTIVE' || - ![1, 2, 3, 4].includes(head.storageVersion) || + ![1, 2, 3, 4, 5].includes(head.storageVersion) || head.storageVersion > grant.supportedStorageVersion || head.writerGeneration !== grant.writerGeneration || head.recoveryStatus !== 'READY' || @@ -3011,7 +3041,7 @@ class ManagedSessionStoreHttpClient { redirect: 'error', headers: { Accept: accept, - 'X-Qwen-Managed-Max-Readable-Storage-Version': '4', + 'X-Qwen-Managed-Max-Readable-Storage-Version': '5', [HTTP_MANAGED_SESSION_STORE_CONTRACT.tenantHeader]: this.sessionKey.tenantId, [HTTP_MANAGED_SESSION_STORE_CONTRACT.writerTokenHeader]: @@ -3510,6 +3540,12 @@ export function collectNestedResourceRefs( parseHostedRecoveryResource(bytes, undefined, ref.kind), ); } + if (ref.kind === MANAGED_TOOL_MEASUREMENTS_KIND) { + if (ref.schemaVersion !== 1) + throw corrupt('Unsupported tool measurements version.'); + parseManagedToolMeasurementEvidence(bytes); + return []; + } if (ref.kind === EXECUTION_DIAGNOSTICS_KIND) { if (ref.schemaVersion !== 1) throw corrupt('Unsupported execution diagnostics version.'); diff --git a/packages/core/src/managed-runtime/managed-execution-diagnostics.test.ts b/packages/core/src/managed-runtime/managed-execution-diagnostics.test.ts index 1a49c6896fd..aa8fd9fe17d 100644 --- a/packages/core/src/managed-runtime/managed-execution-diagnostics.test.ts +++ b/packages/core/src/managed-runtime/managed-execution-diagnostics.test.ts @@ -22,6 +22,10 @@ import type { ChatRecord } from '../services/chatRecordingService.js'; import { assertManagedSessionDurableRef } from './managed-session-records.js'; import type { ManagedSessionKey } from './managed-session-records.js'; import { scanManagedSessionJournal } from './managed-session-storage.js'; +import { + recordManagedToolMeasurements, + parseManagedToolMeasurementEvidence, +} from './managed-tool-measurements.js'; const fixture = JSON.parse( await fs.readFile( @@ -47,7 +51,13 @@ afterEach(async () => { await fs.rm(dir, { recursive: true, force: true }); }); -async function setup(collect = true) { +async function setup( + collect = true, + tool: { name: string; args: Record } = { + name: 'read_file', + args: { file_path: 'a.txt' }, + }, +) { const dir = await fs.mkdtemp( path.join(os.tmpdir(), 'execution-diagnostics-'), ); @@ -90,8 +100,7 @@ async function setup(collect = true) { { functionCall: { id: 'reused', - name: 'read_file', - args: { file_path: 'a.txt' }, + ...tool, }, }, ], @@ -101,6 +110,151 @@ async function setup(collect = true) { } describe('Managed execution diagnostics', () => { + it.each(['original', 'modified', 'unknown'] as const)( + 'records executor evidence once across cancellation and reopen, with proposal provenance %s', + async (proposal) => { + const args = { file_path: 'a.txt', content: 'one\n' }; + const { session, record, options } = await setup(true, { + name: 'write_file', + args, + }); + await session.sink.write(record('measured')); + const authority = session.authority; + const observation = + getManagedExecutionDiagnostics(authority)!.observation('reused')!; + observation.executionCallId = 'exec-1'; + const diagnosticsRef = await getManagedExecutionDiagnostics( + authority, + )!.publish('intent:exec-1', [observation]); + const ref = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from(JSON.stringify({ executionCallId: 'exec-1' })), + ); + const inputRef = + proposal === 'unknown' + ? ref + : await session.resources.publish( + 'managed-tool-input', + Buffer.from( + JSON.stringify({ + payloadJson: JSON.stringify({ + toolName: 'write_file', + input: { + ...args, + content: + proposal === 'modified' ? 'one\ntwo\n' : args.content, + }, + }), + }), + ), + ); + await authority.appendExecutionEvent( + { + operation: 'recordToolIntent', + commandId: 'exec-1', + sessionKey: options.sessionKey, + contentDigest: ref.digest, + }, + (sequence) => ({ + v: 1, + sequence, + eventId: 'intent:exec-1', + sessionKey: options.sessionKey, + kind: 'tool.intent', + occurredAt: Date.now(), + subject: { + type: 'activation', + scopeId: session.activation.activationId, + activationId: session.activation.activationId, + epoch: session.activation.epoch, + }, + payload: { + executionCallId: 'exec-1', + batchId: 'measured', + ordinal: 0, + toolDefinitionRef: ref, + argsRef: inputRef, + outcomeSource: 'runtime', + diagnosticsRef, + }, + }), + { class: 'harness', activation: session.activation }, + ); + const metadata = { + resultDigest: 'b'.repeat(64), + measurements: { + protocol: 'managed-tool-measurements/1' as const, + executionDurationMs: 0, + executionOutcome: 'success' as const, + file: { + operation: 'write' as const, + status: 'measured' as const, + modelLinesAdded: 2, + modelLinesRemoved: 0, + appliedLinesAdded: 2, + appliedLinesRemoved: 0, + }, + }, + }; + await recordManagedToolMeasurements(session, 'exec-1', ref, metadata); + expect( + parseManagedToolMeasurementEvidence( + await session.resources.read( + assertManagedSessionDurableRef( + authority.lastEventOfKind('tool.measured')!.payload[ + 'measurementsRef' + ], + 'measurement', + ), + ), + ).modelProposal, + ).toBe(proposal); + const sequence = authority.committedSequence; + await recordManagedToolMeasurements(session, 'exec-1', ref, metadata); + const cancelled = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from( + JSON.stringify({ executionCallId: 'exec-1', status: 'cancelled' }), + ), + ); + await recordManagedToolMeasurements( + session, + 'exec-1', + cancelled, + metadata, + ); + expect(authority.committedSequence).toBe(sequence); + expect(authority.committedReceipt('exec-1')).toBeUndefined(); + await expect( + recordManagedToolMeasurements(session, 'exec-1', ref, { + ...metadata, + resultDigest: 'c'.repeat(64), + }), + ).rejects.toThrow(); + await session.close(); + sessions.splice(sessions.indexOf(session), 1); + const restored = await openManagedSession(options); + sessions.push(restored); + await recordManagedToolMeasurements(restored, 'exec-1', ref, metadata); + await recordManagedToolMeasurements( + restored, + 'exec-1', + cancelled, + metadata, + ); + expect( + restored.authority + .eventsInSequenceRange(1, restored.authority.committedSequence) + .filter((event) => event.kind === 'tool.measured'), + ).toHaveLength(1); + expect( + restored.authority.lastEventOfKind('tool.measured')?.payload[ + 'toolOutcomeRef' + ], + ).toEqual(ref); + expect(restored.authority.committedReceipt('exec-1')).toBeUndefined(); + }, + ); it.each([false, true])( 'binds committed record digests to diagnostic evidence (collect=%s)', async (collect) => { diff --git a/packages/core/src/managed-runtime/managed-session-records.test.ts b/packages/core/src/managed-runtime/managed-session-records.test.ts index ab35776b68c..d12bdd75795 100644 --- a/packages/core/src/managed-runtime/managed-session-records.test.ts +++ b/packages/core/src/managed-runtime/managed-session-records.test.ts @@ -996,7 +996,7 @@ describe('managed session header', () => { it('refuses a newer format or reader requirement', () => { headerError({ formatVersion: 2 }, /is not supported by this reader/); headerError( - { minimumReader: 'managed-session/3' }, + { minimumReader: 'managed-session/4' }, /is not supported by this reader/, ); }); diff --git a/packages/core/src/managed-runtime/managed-session-records.ts b/packages/core/src/managed-runtime/managed-session-records.ts index e8733aafe94..29b114f269e 100644 --- a/packages/core/src/managed-runtime/managed-session-records.ts +++ b/packages/core/src/managed-runtime/managed-session-records.ts @@ -36,7 +36,7 @@ export const MANAGED_SESSION_FORMAT_VERSION = 1; // created in between (H3 round-5 verification matrix), so the stamp // rises only when a change genuinely breaks an older reader mid-scan. export const MANAGED_SESSION_MINIMUM_READER = 'managed-session/1'; -export const MANAGED_SESSION_READER_CAPABILITY = 'managed-session/2'; +export const MANAGED_SESSION_READER_CAPABILITY = 'managed-session/3'; const MANAGED_SESSION_DOMAIN_RECORD_VERSION = 1; const MAX_ERROR_VALUE_LENGTH = 4096; @@ -54,6 +54,7 @@ export const MANAGED_SESSION_EVENT_KINDS = [ 'tool.intent', 'action.changed', 'tool.receipt', + 'tool.measured', 'checkpoint.committed', 'context.compacted', 'cancel.requested', @@ -1004,6 +1005,13 @@ const EVENT_SCHEMAS: Readonly> = decisionRef: 'refOrNull', }, }, + 'tool.measured': { + fields: { + executionCallId: 'id', + toolOutcomeRef: 'ref', + measurementsRef: 'ref', + }, + }, 'tool.receipt': { fields: { executionCallId: 'id', @@ -1104,6 +1112,7 @@ const EVENT_ACTORS: Readonly< 'hosted.cleanup': ['harness'], 'action.changed': ['harness', 'trusted_entry'], 'tool.receipt': ['trusted_entry'], + 'tool.measured': ['harness'], 'checkpoint.committed': ['harness'], 'context.compacted': ['harness'], 'cancel.requested': ['trusted_entry'], @@ -1129,6 +1138,7 @@ const ACTIVATION_SUBJECT_KINDS: Readonly< 'hosted.cleanup': true, 'action.changed': false, 'tool.receipt': false, + 'tool.measured': true, 'checkpoint.committed': true, 'context.compacted': true, 'cancel.requested': false, @@ -1471,6 +1481,7 @@ function assertPayloadRules( case 'tool.intent': case 'message.delta': case 'tool.receipt': + case 'tool.measured': case 'cancel.requested': case 'turn.settled': return; diff --git a/packages/core/src/managed-runtime/managed-tool-measurements.test.ts b/packages/core/src/managed-runtime/managed-tool-measurements.test.ts new file mode 100644 index 00000000000..2d0e6ac150b --- /dev/null +++ b/packages/core/src/managed-runtime/managed-tool-measurements.test.ts @@ -0,0 +1,58 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { managedToolDigest } from '../tools/managed-tool-protocol.js'; +import { + parseManagedToolMeasurementEvidence, + parseManagedToolMeasurements, +} from './managed-tool-measurements.js'; + +const fixture = JSON.parse( + await readFile( + new URL( + './contracts/managed-tool-measurements-v1.fixtures.json', + import.meta.url, + ), + 'utf8', + ), +) as { + cases: Array<{ id: string; valid: boolean; measurements: unknown }>; + evidence: Record; + wire: unknown; + resultDigest: string; +}; + +describe('Managed executor measurements shared contract', () => { + it.each(fixture.cases)('$id (valid=$valid)', ({ valid, measurements }) => { + if (valid) + expect(parseManagedToolMeasurements(measurements)).toEqual(measurements); + else expect(() => parseManagedToolMeasurements(measurements)).toThrow(); + }); + + it('uses the same canonical UTF-8 result digest as Java', () => { + expect(managedToolDigest(fixture.wire)).toBe(fixture.resultDigest); + expect( + parseManagedToolMeasurementEvidence( + Buffer.from(JSON.stringify(fixture.evidence)), + ), + ).toEqual(fixture.evidence); + }); + + it('bounds evidence bytes and rejects opaque contents and missing bindings', () => { + for (const patch of [ + { invocationId: 'wrong' }, + { executionCallId: 'x'.repeat(513) }, + { parameters: 'private' }, + { schemaVersion: 2 }, + { modelProposal: 'guessed' }, + ]) { + expect(() => + parseManagedToolMeasurementEvidence( + Buffer.from(JSON.stringify({ ...fixture.evidence, ...patch })), + ), + ).toThrow(); + } + expect(() => + parseManagedToolMeasurementEvidence(Buffer.alloc(4097, ' ')), + ).toThrow(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-tool-measurements.ts b/packages/core/src/managed-runtime/managed-tool-measurements.ts new file mode 100644 index 00000000000..57d2c36349b --- /dev/null +++ b/packages/core/src/managed-runtime/managed-tool-measurements.ts @@ -0,0 +1,314 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { ManagedSession } from './managed-session-assembly.js'; +import { + ManagedSessionRecordError, + assertManagedSessionDurableRef, + type ManagedSessionDurableRef, + type ManagedSessionEvent, +} from './managed-session-records.js'; +import { managedInvocationId } from './managed-execution-diagnostics.js'; +import { readManagedMessageBody } from './managed-message-chunks.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; + +export const MANAGED_TOOL_MEASUREMENTS_KIND = 'managed-tool-measurements'; +export const MANAGED_TOOL_MEASUREMENTS_MAX_BYTES = 4096; + +export interface ManagedFileMeasurement { + operation: 'read' | 'write' | 'edit'; + status: 'measured' | 'unchanged' | 'unmeasured'; + modelLinesAdded: number | null; + modelLinesRemoved: number | null; + appliedLinesAdded: number | null; + appliedLinesRemoved: number | null; +} + +export interface ManagedToolMeasurements { + protocol: 'managed-tool-measurements/1'; + executionDurationMs: number | null; + executionOutcome: + | 'not_started' + | 'success' + | 'error' + | 'cancelled' + | 'unknown'; + file: ManagedFileMeasurement | null; +} + +export interface ManagedToolMeasurementEvidence { + schemaVersion: 1; + invocationId: string; + executionCallId: string; + resultDigest: string; + modelProposal: 'original' | 'modified' | 'unknown'; + measurements: ManagedToolMeasurements | null; +} + +export interface ManagedToolResultMeasurements { + measurements: ManagedToolMeasurements | null; + resultDigest: string; +} + +function invalid(): never { + throw new ManagedSessionRecordError('Invalid Managed tool measurements.'); +} + +function closed(value: unknown, keys: string[]): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + const record = value as Record; + if (Object.keys(record).sort().join(',') !== keys.sort().join(',')) invalid(); + return record; +} + +function count(value: unknown): boolean { + return value === null || (Number.isSafeInteger(value) && Number(value) >= 0); +} + +export function parseManagedToolMeasurements( + value: unknown, +): ManagedToolMeasurements { + const body = closed(value, [ + 'protocol', + 'executionDurationMs', + 'executionOutcome', + 'file', + ]); + if ( + body['protocol'] !== 'managed-tool-measurements/1' || + !count(body['executionDurationMs']) || + !['not_started', 'success', 'error', 'cancelled', 'unknown'].includes( + String(body['executionOutcome']), + ) + ) + invalid(); + if (body['file'] !== null) { + const file = closed(body['file'], [ + 'operation', + 'status', + 'modelLinesAdded', + 'modelLinesRemoved', + 'appliedLinesAdded', + 'appliedLinesRemoved', + ]); + const fields = [ + 'modelLinesAdded', + 'modelLinesRemoved', + 'appliedLinesAdded', + 'appliedLinesRemoved', + ]; + if ( + body['executionOutcome'] !== 'success' || + !['read', 'write', 'edit'].includes(String(file['operation'])) || + !['measured', 'unchanged', 'unmeasured'].includes( + String(file['status']), + ) || + !fields.every((field) => count(file[field])) || + (file['modelLinesAdded'] === null) !== + (file['modelLinesRemoved'] === null) || + (file['appliedLinesAdded'] === null) !== + (file['appliedLinesRemoved'] === null) + ) + invalid(); + if (file['operation'] === 'read') { + if ( + file['status'] !== 'measured' || + !fields.every((field) => file[field] === null) + ) + invalid(); + } else if (file['status'] === 'unmeasured') { + if (file['appliedLinesAdded'] !== null) invalid(); + } else if ( + fields.some((field) => file[field] === null) || + (file['status'] === 'unchanged') !== + (file['appliedLinesAdded'] === 0 && file['appliedLinesRemoved'] === 0) + ) + invalid(); + } + return structuredClone(body) as unknown as ManagedToolMeasurements; +} + +export function parseManagedToolMeasurementEvidence( + bytes: Buffer, +): ManagedToolMeasurementEvidence { + if (bytes.byteLength > MANAGED_TOOL_MEASUREMENTS_MAX_BYTES) invalid(); + const body = closed(JSON.parse(bytes.toString('utf8')), [ + 'schemaVersion', + 'invocationId', + 'executionCallId', + 'resultDigest', + 'modelProposal', + 'measurements', + ]); + if ( + body['schemaVersion'] !== 1 || + typeof body['invocationId'] !== 'string' || + !/^[0-9a-f]{64}$/.test(body['invocationId']) || + typeof body['resultDigest'] !== 'string' || + !/^[0-9a-f]{64}$/.test(body['resultDigest']) || + typeof body['executionCallId'] !== 'string' || + !body['executionCallId'].length || + Buffer.byteLength(body['executionCallId']) > 512 || + /[\ud800-\udfff]/u.test(body['executionCallId']) || + !['original', 'modified', 'unknown'].includes(String(body['modelProposal'])) + ) + invalid(); + if (body['measurements'] !== null) + parseManagedToolMeasurements(body['measurements']); + return body as unknown as ManagedToolMeasurementEvidence; +} + +export function toolMeasurementCommitDigest( + executionCallId: string, + outcome: ManagedSessionDurableRef, + measurements: ManagedSessionDurableRef, +): string { + return createHash('sha256') + .update( + JSON.stringify([ + 'managed-tool-measured/1', + executionCallId, + outcome.digest, + measurements.digest, + ]), + ) + .digest('hex'); +} + +async function modelProposal( + session: ManagedSession, + intent: ManagedSessionEvent, + measurements: ManagedToolMeasurements | null, +): Promise { + if (!measurements?.file || measurements.file.operation === 'read') + return 'unknown'; + const source = session.authority.committedEvent( + `message:${String(intent.payload['batchId'])}`, + ); + if (!source) return 'unknown'; + const inputRef = assertManagedSessionDurableRef( + intent.payload['argsRef'], + 'tool input', + ); + if (inputRef.kind !== 'managed-tool-input') return 'unknown'; + const record = JSON.parse( + ( + await readManagedMessageBody( + (ref) => session.resources.read(ref), + assertManagedSessionDurableRef(source.payload['contentRef'], 'message'), + ) + ).toString('utf8'), + ) as ChatRecord; + const call = record.message?.parts?.filter((part) => part.functionCall)[ + Number(intent.payload['ordinal']) + ]?.functionCall; + const routed = JSON.parse( + (await session.resources.read(inputRef)).toString(), + ); + const dispatched = JSON.parse(routed.payloadJson) as { + toolName: string; + input: unknown; + }; + if (!call || call.name !== dispatched.toolName) return 'unknown'; + return isDeepStrictEqual(call.args, dispatched.input) + ? 'original' + : 'modified'; +} + +/** A separate source event preserves receipts already committed by v3 publication. */ +export async function recordManagedToolMeasurements( + session: ManagedSession, + executionCallId: string, + toolOutcomeRef: ManagedSessionDurableRef, + result: ManagedToolResultMeasurements | undefined, +): Promise { + if (!result) return; + const authority = session.authority; + const events = authority.eventsInSequenceRange( + 1, + authority.committedSequence, + ); + const intent = events.find( + (event) => + event.kind === 'tool.intent' && + event.payload['executionCallId'] === executionCallId, + ); + // An execution admitted before collection started stays outside that window. + if (!intent?.payload['diagnosticsRef']) return; + const body: ManagedToolMeasurementEvidence = { + schemaVersion: 1, + invocationId: managedInvocationId( + authority.sessionHeader.sessionKey, + String(intent.payload['batchId']), + Number(intent.payload['ordinal']), + ), + executionCallId, + resultDigest: result.resultDigest, + modelProposal: await modelProposal(session, intent, result.measurements), + measurements: result.measurements, + }; + const bytes = Buffer.from(JSON.stringify(body)); + parseManagedToolMeasurementEvidence(bytes); + const existing = events.find( + (event) => + event.kind === 'tool.measured' && + event.payload['executionCallId'] === executionCallId, + ); + if (existing) { + const ref = assertManagedSessionDurableRef( + existing.payload['measurementsRef'], + 'tool measurements', + ); + if ( + !isDeepStrictEqual( + parseManagedToolMeasurementEvidence(await session.resources.read(ref)), + body, + ) + ) + invalid(); + return; + } + const measurementsRef = await session.resources.publish( + MANAGED_TOOL_MEASUREMENTS_KIND, + bytes, + ); + await authority.appendExecutionEvent( + { + operation: 'recordToolMeasurements', + commandId: executionCallId, + sessionKey: authority.sessionHeader.sessionKey, + contentDigest: toolMeasurementCommitDigest( + executionCallId, + toolOutcomeRef, + measurementsRef, + ), + }, + (sequence) => ({ + v: 1, + sequence, + eventId: `tool-measured:${executionCallId}`, + sessionKey: authority.sessionHeader.sessionKey, + kind: 'tool.measured', + occurredAt: Date.now(), + subject: { + type: 'activation', + scopeId: session.activation.activationId, + activationId: session.activation.activationId, + epoch: session.activation.epoch, + }, + payload: { executionCallId, toolOutcomeRef, measurementsRef }, + }), + { + class: 'harness', + activation: { + activationId: session.activation.activationId, + epoch: session.activation.epoch, + }, + }, + ); +} diff --git a/packages/managed-agent-server/configuration-descriptions.json b/packages/managed-agent-server/configuration-descriptions.json index 1d4d0826b33..b0c59d5683e 100644 --- a/packages/managed-agent-server/configuration-descriptions.json +++ b/packages/managed-agent-server/configuration-descriptions.json @@ -829,6 +829,10 @@ "en": "The database user the control plane connects as. Besides rights on the control plane's database, it needs `SELECT` on `performance_schema.user_variables_by_thread`, which the preflight `--check` verifies.", "zh": "控制面连接数据库所用的用户。除控制面数据库的权限外,还需要 `performance_schema.user_variables_by_thread` 的 `SELECT` 权限,预检 `--check` 会校验该权限。", "type": "string" + }, + "qwen.managed-agent.runtime-broker.tool-measurements": { + "en": "Whether newly resolved ordinary Workspace bindings select the attested managed-workspace-execution/2 profile and Tool v4 numeric execution/file measurements. Defaults to false; existing bindings, extension/MCP owners, resident and CSI profiles retain their contracts. Deploy compatible Worker/Broker readers first. Measurement commits reuse existing activity evidence and require a storage-version-5 grant; no separate collector flag is introduced. See the [execution diagnostics design](../design/2026-11-07-managed-execution-diagnostics.md).", + "zh": "是否让新解析的普通 Workspace binding 选择经过 attestation 的 managed-workspace-execution/2 profile 和 Tool v4 数值执行/文件测量。默认 false;已有 binding、extension/MCP owner、resident 和 CSI profile 保留原契约。先部署兼容 Worker/Broker reader。测量提交复用现有 activity evidence,并要求 storage grant 支持 version 5;不引入独立 collector 开关。参见[执行诊断设计](../design/2026-11-07-managed-execution-diagnostics.zh-CN.md)。" } } } diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/event/ManagedEventEnvelopes.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/event/ManagedEventEnvelopes.java index 39c43751325..a0d999660a0 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/event/ManagedEventEnvelopes.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/event/ManagedEventEnvelopes.java @@ -52,12 +52,12 @@ public final class ManagedEventEnvelopes { private static final Pattern DIGEST = Pattern.compile("[0-9a-f]{64}"); public static final String KIND_WAKE_REQUESTED = "wake.requested"; - // Ordered (the failure message joins it) — the frozen contract's 20-kind - // vocabulary in fixture order. + // Ordered (the failure message joins it) — the journal vocabulary + // in fixture order. public static final List KINDS = List.of( "input.accepted", KIND_WAKE_REQUESTED, "activation.changed", "model.attempt", "message.committed", "tool.intent", - "action.changed", "tool.receipt", "checkpoint.committed", + "action.changed", "tool.receipt", "tool.measured", "checkpoint.committed", "context.compacted", "cancel.requested", "turn.settled", "config.bound", "lifecycle.changed", "domain.committed", "message.delta", "message.retracted", "operation.replayed", diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedActivityDiagnostics.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedActivityDiagnostics.java index c7878e16b1d..24f291cd2fd 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedActivityDiagnostics.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedActivityDiagnostics.java @@ -68,6 +68,18 @@ public final class ManagedActivityDiagnostics { return DigestUtils.sha256Hex(digest.toString().getBytes(StandardCharsets.UTF_8)); } + public static boolean matchesCommit(List events, String operation, String commandId, String digest) { + for (JsonNode event : events) { + if ("tool.measured".equals(event.path("kind").asText())) { + return events.size() == 1 && "recordToolMeasurements".equals(operation) + && event.path("payload").path("executionCallId").asText().equals(commandId) + && ManagedToolMeasurements.commitDigest(event.path("payload")).equals(digest); + } + } + return events.stream().noneMatch(event -> event.path("payload").has("diagnosticsRef")) + || carrierDigest(events).equals(digest); + } + public static void requireEvidence(JsonNode body) { closed(body, Set.of("schemaVersion", "reducerVersion", "epochId", "startEventId", "sourceEventId", "observations", "coverageReasons", "omittedObservationCount"), "execution diagnostics"); @@ -184,6 +196,9 @@ public final class ManagedActivityDiagnostics { next.set(field, resource); continue; } + if ("execution".equals(field) && before.path("_measurementOutcomeKnown").asBoolean()) { + continue; + } if ("execution".equals(field) && List.of("success", "error", "cancelled").contains(before.path(field).asText()) && !before.path(field).equals(value) && !executorAuthority) { next.put("conflictingEvidence", true); @@ -232,11 +247,30 @@ public final class ManagedActivityDiagnostics { contribution(summary, before, -1); } contribution(summary, after, 1); + measurementContribution(summary, before, -1); + measurementContribution(summary, after, 1); + for (String field : List.of("tools", "files")) { + ManagedToolMeasurements.availability((ObjectNode) summary.path(field)); + } + for (String operation : List.of("read", "write", "edit")) { + ManagedToolMeasurements.availability((ObjectNode) summary.path("fileOperations").path(operation)); + } if (after.path("conflictingEvidence").asBoolean()) { gap(summary, "conflicting_evidence"); } } + private static void measurementContribution(ObjectNode summary, JsonNode fact, int delta) { + if (fact == null) { + return; + } + ManagedToolMeasurements.contribute((ObjectNode) summary.path("tools"), fact, delta); + if (!fact.path("file").isNull()) { + ManagedToolMeasurements.contribute((ObjectNode) summary.path("files"), fact, delta); + ManagedToolMeasurements.contribute((ObjectNode) summary.path("fileOperations").path(fact.path("file").path("operation").asText()), fact, delta); + } + } + private static void contribution(ObjectNode summary, JsonNode fact, int delta) { add((ObjectNode) summary.path("tools"), fact, delta); if ("unknown".equals(fact.path("toolKind").asText())) { diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedExtensionRecords.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedExtensionRecords.java index 0cd6cd4a761..bb8803b8c9f 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedExtensionRecords.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedExtensionRecords.java @@ -44,7 +44,7 @@ public final class ManagedExtensionRecords { public static final List EVENT_KINDS = List.of("input.accepted", "wake.requested", "activation.changed", "model.attempt", "message.committed", "tool.intent", "action.changed", - "tool.receipt", "checkpoint.committed", "context.compacted", + "tool.receipt", "tool.measured", "checkpoint.committed", "context.compacted", "cancel.requested", "turn.settled", "config.bound", "lifecycle.changed", "domain.committed", "message.delta", "message.retracted", "operation.replayed", "hosted.batch.planned", "hosted.cleanup"); diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedStorageVersions.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedStorageVersions.java index 28b9e82a659..b180413758a 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedStorageVersions.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedStorageVersions.java @@ -1,6 +1,6 @@ package com.alibaba.qwen.code.managedagent.contracts.persistence; public final class ManagedStorageVersions { - public static final int SUPPORTED = 4; + public static final int SUPPORTED = 5; private ManagedStorageVersions() { } } diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedToolMeasurements.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedToolMeasurements.java new file mode 100644 index 00000000000..6d563dd89dd --- /dev/null +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedToolMeasurements.java @@ -0,0 +1,172 @@ +package com.alibaba.qwen.code.managedagent.contracts.persistence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.codec.digest.DigestUtils; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; + +import static com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords.closed; +import static com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords.count; +import static com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords.oneOf; + +/** Bounded executor evidence; observation v1 and legacy receipts stay unchanged. */ +public final class ManagedToolMeasurements { + public static final String KIND = "managed-tool-measurements"; + public static final int MAX_BYTES = 4096; + private static final long MAX_COUNT = 9_007_199_254_740_991L; + private static final List LINES = List.of("modelLinesAdded", "modelLinesRemoved", "appliedLinesAdded", "appliedLinesRemoved"); + private static final List COUNTERS = List.of("recorded", "unsupported", "partial", "executionDurationMs", "executionDurationSamples", + "fileSamples", "modelDiffSamples", "appliedDiffSamples", "modelLinesAdded", "modelLinesRemoved", "appliedLinesAdded", "appliedLinesRemoved"); + + private ManagedToolMeasurements() { } + + public static void requireMeasurements(JsonNode body) { + closed(body, Set.of("protocol", "executionDurationMs", "executionOutcome", "file"), "tool measurements"); + require("managed-tool-measurements/1".equals(body.path("protocol").asText()), "Invalid measurement protocol."); + nullableCount(body.get("executionDurationMs")); + oneOf(body.get("executionOutcome"), List.of("not_started", "success", "error", "cancelled", "unknown"), "executionOutcome"); + JsonNode file = body.get("file"); + require(file != null, "Missing file measurement."); + if (file.isNull()) { + return; + } + require("success".equals(body.path("executionOutcome").asText()), "File measurement requires execution success."); + closed(file, Set.of("operation", "status", "modelLinesAdded", "modelLinesRemoved", "appliedLinesAdded", "appliedLinesRemoved"), "file measurement"); + oneOf(file.get("operation"), List.of("read", "write", "edit"), "operation"); + oneOf(file.get("status"), List.of("measured", "unchanged", "unmeasured"), "status"); + LINES.forEach(field -> nullableCount(file.get(field))); + require(file.path("modelLinesAdded").isNull() == file.path("modelLinesRemoved").isNull() + && file.path("appliedLinesAdded").isNull() == file.path("appliedLinesRemoved").isNull(), "Incomplete line measurement."); + if ("read".equals(file.path("operation").asText())) { + require("measured".equals(file.path("status").asText()) && LINES.stream().allMatch(field -> file.path(field).isNull()), "Invalid read measurement."); + } else if ("unmeasured".equals(file.path("status").asText())) { + require(file.path("appliedLinesAdded").isNull(), "Unmeasured changes have no applied diff."); + } else { + require(LINES.stream().noneMatch(field -> file.path(field).isNull()) + && "unchanged".equals(file.path("status").asText()) == (file.path("appliedLinesAdded").longValue() == 0 + && file.path("appliedLinesRemoved").longValue() == 0), "Invalid measured changes."); + } + } + + public static void requireEvidence(JsonNode body) { + closed(body, Set.of("schemaVersion", "invocationId", "executionCallId", "resultDigest", "modelProposal", "measurements"), "tool measurement evidence"); + count(body.get("schemaVersion"), 1, 1, "schemaVersion"); + oneOf(body.get("modelProposal"), List.of("original", "modified", "unknown"), "modelProposal"); + for (String field : List.of("invocationId", "resultDigest")) { + require(body.path(field).isTextual() && body.path(field).asText().matches("[0-9a-f]{64}"), "Invalid measurement digest."); + } + JsonNode execution = body.path("executionCallId"); + require(execution.isTextual() && !execution.asText().isEmpty() && execution.asText().getBytes(StandardCharsets.UTF_8).length <= 512 + && execution.asText().codePoints().noneMatch(code -> code >= 0xd800 && code <= 0xdfff), "Invalid measurement execution identity."); + require(body.has("measurements") && body.toString().getBytes(StandardCharsets.UTF_8).length <= MAX_BYTES, "Measurement evidence exceeds its limit."); + if (!body.path("measurements").isNull()) { + requireMeasurements(body.get("measurements")); + } + } + + public static String commitDigest(JsonNode payload) { + return DigestUtils.sha256Hex(JsonNodeFactory.instance.arrayNode().add("managed-tool-measured/1") + .add(payload.path("executionCallId").asText()).add(payload.path("toolOutcomeRef").path("digest").asText()) + .add(payload.path("measurementsRef").path("digest").asText()).toString().getBytes(StandardCharsets.UTF_8)); + } + + public static ObjectNode publicMeasurements(JsonNode body, long sequence) { + JsonNode packet = body.path("measurements"); + ObjectNode result = JsonNodeFactory.instance.objectNode().put("sourceSequence", Long.toString(sequence)); + result.put("availability", packet.isNull() ? "unsupported" + : packet.path("executionDurationMs").isNull() || "unmeasured".equals(packet.path("file").path("status").asText()) ? "partial" : "available"); + if (packet.isNull() || packet.path("executionDurationMs").isNull()) { + result.putNull("executionDurationMs"); + } else { + result.put("executionDurationMs", Long.toString(packet.path("executionDurationMs").longValue())); + } + result.set("file", packet.path("file").isMissingNode() ? JsonNodeFactory.instance.nullNode() : packet.path("file").deepCopy()); + if (result.path("file").isObject()) { + ObjectNode file = (ObjectNode) result.path("file"); + LINES.forEach(field -> { + if (!file.path(field).isNull()) { + file.put(field, Long.toString(file.path(field).longValue())); + } + }); + if (!"read".equals(file.path("operation").asText()) && !"original".equals(body.path("modelProposal").asText())) { + file.putNull("modelLinesAdded").putNull("modelLinesRemoved"); + result.put("availability", "partial"); + } + } + return result; + } + + public static void contribute(ObjectNode metrics, JsonNode fact, int sign) { + if (fact == null || !fact.has("measurements")) { + return; + } + ObjectNode totals; + if (metrics.path("measurements").isObject()) { + totals = (ObjectNode) metrics.path("measurements"); + } else { + totals = metrics.putObject("measurements"); + COUNTERS.forEach(field -> totals.put(field, "0")); + } + JsonNode measured = fact.path("measurements"); + add(totals, "recorded", "1", sign); + if ("unsupported".equals(measured.path("availability").asText())) { + add(totals, "unsupported", "1", sign); + } + if ("partial".equals(measured.path("availability").asText())) { + add(totals, "partial", "1", sign); + } + if (!measured.path("executionDurationMs").isNull()) { + add(totals, "executionDurationMs", measured.path("executionDurationMs").asText(), sign); + add(totals, "executionDurationSamples", "1", sign); + } + JsonNode file = measured.path("file"); + if (!file.isNull()) { + add(totals, "fileSamples", "1", sign); + for (String pair : List.of("model", "applied")) { + if (!file.path(pair + "LinesAdded").isNull()) { + add(totals, pair + "DiffSamples", "1", sign); + for (String direction : List.of("Added", "Removed")) { + add(totals, pair + "Lines" + direction, file.path(pair + "Lines" + direction).asText(), sign); + } + } + } + } + } + + public static void availability(ObjectNode metrics) { + if (metrics.path("measurements").isObject()) { + ObjectNode totals = (ObjectNode) metrics.path("measurements"); + String recorded = totals.path("recorded").asText(); + totals.put("availability", "0".equals(recorded) ? "not_recorded" + : recorded.equals(totals.path("unsupported").asText()) ? "unsupported" + : recorded.equals(metrics.path("requested").asText()) && "0".equals(totals.path("unsupported").asText()) && "0".equals(totals.path("partial").asText()) ? "available" : "partial"); + } + } + + private static void add(ObjectNode totals, String field, String value, int sign) { + BigInteger next = new BigInteger(totals.path(field).asText()).add(new BigInteger(value).multiply(BigInteger.valueOf(sign))); + require(next.signum() >= 0, "Negative measurement contribution."); + totals.put(field, next.toString()); + } + + private static void nullableCount(JsonNode value) { + require(value != null, "Missing measurement count."); + if (!value.isNull()) { + BigDecimal number = value.isNumber() && Double.isFinite(value.doubleValue()) ? value.decimalValue() : null; + require(number != null && number.stripTrailingZeros().scale() <= 0 && number.signum() >= 0 + && number.compareTo(BigDecimal.valueOf(MAX_COUNT)) <= 0, "Invalid measurement count."); + } + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new ManagedExtensionRecords.InvalidRecordException(message); + } + } +} diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java index 215153de891..94a1ad69d67 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java @@ -158,6 +158,10 @@ public interface ManagedAgentSettings { public String getWorkspaceCwd(); public String getIsolationClass(); public String getBoundSessionIsolationClass(); + public default boolean isToolMeasurements() { + return false; + } + public boolean isSessionCreateWarm(); public List getWorkspaceMounts(); public String getWorkspaceCreationRoot(); diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeToolMeasurements.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeToolMeasurements.java new file mode 100644 index 00000000000..c52e55d7789 --- /dev/null +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeToolMeasurements.java @@ -0,0 +1,100 @@ +package com.alibaba.qwen.code.runtimebroker; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements; +import com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.codec.digest.DigestUtils; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** Private v4 transport metadata; never part of a legacy tool-result envelope. */ +public final class RuntimeToolMeasurements { + public static final String FIELD = "diagnostics"; + + private RuntimeToolMeasurements() {} + + public static boolean selected(Map reference) { + return Integer.valueOf(4).equals(reference.get("resultProtocol")); + } + + public static int executionProtocol(Map reference) { + return "deferred_v3".equals(reference.get("dispatchMode")) + || Integer.valueOf(3).equals(reference.get("runtimeProtocol")) + ? 3 + : 2; + } + + public static void requireProfile(RuntimeSession session) { + if (session == null + || !WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST.equals( + session.getScope().getCapabilityDigest())) { + throw new RuntimeBrokerException( + 501, + "runtime_tool_v4_unsupported", + "Runtime measurement profile is unavailable.", + false); + } + } + + public static Map base(Map result) { + Map base = new LinkedHashMap<>(result); + base.remove(FIELD); + return BrokerValues.immutableMap(base); + } + + public static Map attach( + int protocol, Map result, Object measurements, Object digest) { + if (!(digest instanceof String value) || !value.matches("[0-9a-f]{64}")) { + throw new RuntimeBrokerException( + 502, "runtime_tool_v4_invalid", "Measured result digest is invalid.", false); + } + Map body = new LinkedHashMap<>(); + body.put("executionProtocol", protocol); + body.put("result", result); + body.put("measurements", measurements); + JsonNode node = + ManagedExtensionRecordStore.parse( + new String(JsonCodec.encode(body), StandardCharsets.UTF_8)); + if (node == null + || !value.equals(DigestUtils.sha256Hex(CsiNativeActivationProof.canonical(node)))) { + throw new RuntimeBrokerException( + 502, "runtime_tool_v4_invalid", "Measured result digest conflicts.", false); + } + if (measurements != null) { + JsonNode measured = node.get("measurements"); + ManagedToolMeasurements.requireMeasurements(measured); + } + Map metadata = new LinkedHashMap<>(); + metadata.put("protocolVersion", 4); + metadata.put("executionProtocol", protocol); + metadata.put("measurements", measurements); + metadata.put("resultDigest", value); + Map combined = new LinkedHashMap<>(result); + combined.put(FIELD, BrokerValues.immutableMap(metadata)); + return BrokerValues.immutableMap(combined); + } + + public static Map metadata(Map result) { + Object raw = result.get(FIELD); + if (!(raw instanceof Map map) + || !map.keySet() + .equals( + Set.of( + "protocolVersion", + "executionProtocol", + "measurements", + "resultDigest"))) { + throw new RuntimeBrokerException( + 409, + "runtime_tool_measurements_missing", + "Original measured result is unavailable.", + false); + } + @SuppressWarnings("unchecked") + Map metadata = (Map) map; + return metadata; + } +} diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java index c189c598a0f..e6d0755bfca 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeTransport.java @@ -20,6 +20,57 @@ import java.util.concurrent.CompletionStage; * completion only with {@code true}. */ public interface RuntimeTransport { + public default CompletionStage> executeV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + Map payload, + Map capture) { + return CompletableFuture.failedFuture( + new RuntimeBrokerException( + 501, + "runtime_tool_v4_unsupported", + "Runtime measurements are unavailable.", + false)); + } + + public default CompletionStage> statusV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + long afterSequence) { + return CompletableFuture.failedFuture( + new RuntimeBrokerException( + 501, + "runtime_tool_v4_unsupported", + "Runtime measurements are unavailable.", + false)); + } + + public default CompletionStage> cancelV4( + RuntimeLease lease, RuntimeSession session, Map reference) { + return CompletableFuture.failedFuture( + new RuntimeBrokerException( + 501, + "runtime_tool_v4_unsupported", + "Runtime measurements are unavailable.", + false)); + } + + public default CompletionStage acknowledgeV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + Map result, + Map receipt) { + return CompletableFuture.failedFuture( + new RuntimeBrokerException( + 501, + "runtime_tool_v4_unsupported", + "Runtime measurements are unavailable.", + false)); + } + public default CompletionStage> extensionControl(RuntimeBindingRecord runtime, String harnessSessionId, Map operation) { return CompletableFuture.failedFuture(new RuntimeBrokerException(501, diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRecord.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRecord.java index bce9add05c2..012983086d3 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRecord.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRecord.java @@ -412,6 +412,22 @@ public final class ToolExecutionRecord { dispatchGeneration, version, completionTime); } + public ToolExecutionRecord withMeasurements(Map metadata) { + if (!isSettled() || !RuntimeToolMeasurements.selected(reference) || result == null) { + throw new IllegalStateException( + "Only a measured settled execution accepts observations"); + } + if (result.containsKey(RuntimeToolMeasurements.FIELD)) { + if (!BrokerValues.sameJsonMap(RuntimeToolMeasurements.metadata(result), metadata)) { + throw new IllegalArgumentException("Measured result metadata conflicts"); + } + return this; + } + Map enriched = new java.util.LinkedHashMap<>(result); + enriched.put(RuntimeToolMeasurements.FIELD, metadata); + return withResult(enriched, lastSequence, settledAt); + } + public static Map cancelledBeforeV3Start() { Map result = new LinkedHashMap<>(); result.put("executionStatus", "not_started"); diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRepository.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRepository.java index fbe537be310..c6d496923aa 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRepository.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/ToolExecutionRepository.java @@ -8,6 +8,18 @@ import java.util.Set; /** Persistence boundary for idempotent Tool execution state. */ public interface ToolExecutionRepository { + /** + * Adds observational metadata to the identical settled execution; cannot change its outcome. + */ + public default ToolExecutionRecord attachMeasurements( + ToolExecutionRecord expected, Map metadata) { + throw new RuntimeBrokerException( + 501, + "runtime_tool_v4_unsupported", + "Measured result persistence is unavailable.", + false); + } + public ToolExecutionRecord findOrCreate(ToolExecutionRecord candidate); public ToolExecutionRecord findByExecutionCallId(String executionCallId); diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java index 7b920bf06c0..6fd5a5a7d9e 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/runtimebroker/WorkspaceExecutionProfile.java @@ -9,6 +9,9 @@ public final class WorkspaceExecutionProfile { "sha256:5fa15183dcfe582ce54c28bf25aff62801d19038a9ec649e9bfc67b06f2a389f"; public static final String CAPABILITY_DIGEST = "sha256:7df3981fa14b397fe09f6ba84ed0c096c71902394da667bf47f3b315866e7f91"; + public static final String MEASURED_PROFILE = "managed-workspace-execution/2"; + public static final String MEASURED_CAPABILITY_DIGEST = + "sha256:b2ab403f278282eb8f2ca06c926be96bd3d85877714f64dc926eab535f544580"; private WorkspaceExecutionProfile() { } @@ -23,7 +26,8 @@ public final class WorkspaceExecutionProfile { * profile), which participates in Harness Session admission fencing. */ public static boolean admitsCapabilityDigest(String digest) { - return CAPABILITY_DIGEST.equals(digest) || ManagedExtensionExecutionProfile.admits(digest); + return CAPABILITY_DIGEST.equals(digest) + || MEASURED_CAPABILITY_DIGEST.equals(digest) || ManagedExtensionExecutionProfile.admits(digest); } public static boolean isBoundWorkspaceScope(RuntimeScope scope) { diff --git a/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json index 733aa1c8742..4aa87ee6e1d 100644 --- a/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -19032,6 +19032,9 @@ "minimum": 0 } } + }, + "measurements": { + "$ref": "#/components/schemas/ManagedActivityMeasurementTotals" } } }, @@ -19240,6 +19243,9 @@ }, "taskRuntimeState": { "type": ["string", "null"] + }, + "measurements": { + "$ref": "#/components/schemas/ManagedActivityMeasurement" } } }, @@ -19367,6 +19373,11 @@ "$ref": "#/components/schemas/ManagedActivityMetrics" } } + }, + "measurementStartSequence": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 } } }, @@ -19801,6 +19812,9 @@ }, "taskRuntimeState": { "type": ["string", "null"] + }, + "measurements": { + "$ref": "#/components/schemas/ManagedActivityMeasurement" } } } @@ -20055,6 +20069,9 @@ }, "taskRuntimeState": { "type": ["string", "null"] + }, + "measurements": { + "$ref": "#/components/schemas/ManagedActivityMeasurement" } } } @@ -20111,6 +20128,201 @@ "$ref": "#/components/schemas/ManagedActivitySkillsPage" } ] + }, + "ManagedActivityMeasurement": { + "type": "object", + "additionalProperties": false, + "required": [ + "sourceSequence", + "availability", + "executionDurationMs", + "file" + ], + "properties": { + "sourceSequence": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "availability": { + "type": "string", + "enum": ["available", "partial", "unsupported"] + }, + "executionDurationMs": { + "anyOf": [ + { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "file": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "status", + "modelLinesAdded", + "modelLinesRemoved", + "appliedLinesAdded", + "appliedLinesRemoved" + ], + "properties": { + "operation": { + "type": "string", + "enum": ["read", "write", "edit"] + }, + "status": { + "type": "string", + "enum": ["measured", "unchanged", "unmeasured"] + }, + "modelLinesAdded": { + "anyOf": [ + { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "modelLinesRemoved": { + "anyOf": [ + { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "appliedLinesAdded": { + "anyOf": [ + { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "appliedLinesRemoved": { + "anyOf": [ + { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + { + "type": "null" + } + ] + } + } + }, + { + "type": "null" + } + ] + } + } + }, + "ManagedActivityMeasurementTotals": { + "type": "object", + "additionalProperties": false, + "required": [ + "availability", + "recorded", + "unsupported", + "executionDurationMs", + "executionDurationSamples", + "fileSamples", + "modelDiffSamples", + "appliedDiffSamples", + "modelLinesAdded", + "modelLinesRemoved", + "appliedLinesAdded", + "appliedLinesRemoved", + "partial" + ], + "properties": { + "availability": { + "type": "string", + "enum": ["available", "partial", "not_recorded", "unsupported"] + }, + "recorded": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "unsupported": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "executionDurationMs": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "executionDurationSamples": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "fileSamples": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "modelDiffSamples": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "appliedDiffSamples": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "modelLinesAdded": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "modelLinesRemoved": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "appliedLinesAdded": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "appliedLinesRemoved": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + }, + "partial": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 128 + } + } } } } diff --git a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedSessionActivityService.java b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedSessionActivityService.java index 0c3ea58f6a3..5f174ace404 100644 --- a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedSessionActivityService.java +++ b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedSessionActivityService.java @@ -37,6 +37,9 @@ public class ManagedSessionActivityService { for (String field : List.of("tools", "files", "fileOperations", "skills", "skillLoads", "extensionCommands", "toolBuckets", "skillBuckets", "otherTools", "otherSkills")) { value.set(field, statistics.path(field).deepCopy()); } + if (statistics.has("measurementStartSequence")) { + value.set("measurementStartSequence", statistics.get("measurementStartSequence")); + } for (String field : List.of("toolBuckets", "skillBuckets")) { for (JsonNode bucket : value.path(field)) { if (bucket.path("resource").isObject()) { @@ -78,6 +81,7 @@ public class ManagedSessionActivityService { var items = result.putArray("items"); for (JsonNode saved : page.items()) { ObjectNode item = saved.deepCopy(); + item.remove(List.of("_measurementEvidenceDigest", "_measurementOutcomeKnown")); if (!saved.path("taskId").isNull()) { records.findTask(tenant, session, saved.path("taskId").asText()).ifPresent(task -> { item.put("taskState", task.projection().state()); diff --git a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java index 8211df79c91..5f31035a381 100644 --- a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java +++ b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeProvisioner.java @@ -84,7 +84,7 @@ public final class WorkspaceRuntimeProvisioner implements RuntimeProvisioner { } if (request.getIsolationKey() != null) { var resolved = resolver.resolve(request.getIsolationKey(), request.getScope().getLifecycleAuthority()); - if (!resolved.scope().equals(request.getScope()) + if (!WorkspaceRuntimeResolver.matchesRecordedScope(resolved.scope(), request.getScope()) || !resolved.binding().getStorageId().equals(request.getStorageId())) { throw com.alibaba.qwen.code.managedagent.repository.WorkspaceExecutionStore.unavailable(); } diff --git a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java index 28b7c44f141..834c1e3fe37 100644 --- a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java +++ b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeResolver.java @@ -27,6 +27,7 @@ public final class WorkspaceRuntimeResolver { private final Map mounts = new LinkedHashMap<>(); private final String boundSessionIsolationClass; private final boolean local; + private final boolean measuredTools; private final ManagedAgentSettings.ManagedExtensions managedExtensions; public WorkspaceRuntimeResolver(SessionQueryStore sessions, WorkspaceExecutionStore authority, @@ -38,6 +39,7 @@ public final class WorkspaceRuntimeResolver { ManagedAgentSettings properties, SshWorkspaceService ssh) { this.ssh = ssh; this.managedExtensions = properties.getManagedExtensions(); + this.measuredTools = properties.getRuntimeBroker().isToolMeasurements(); if (managedExtensions.isEnabled() && !properties.getExtensions().isEnabled()) { throw new IllegalStateException("Managed extension execution requires extensions to be enabled"); } @@ -47,7 +49,9 @@ public final class WorkspaceRuntimeResolver { this.local = com.alibaba.qwen.code.runtimebroker.protocol.RuntimeProviderKinds.LOCAL_PROCESS.equals(broker.getProvisioner()); this.boundSessionIsolationClass = broker.getBoundSessionIsolationClass(); this.residents = new com.alibaba.qwen.code.managedagent.contracts.workspace.ResidentWorkspaceCatalog(broker.getResidentWorkspaces()); - if (WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals( + if (WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST.equals( + properties.getHarness().getCapabilityDigest()) + || WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals( properties.getHarness().getCapabilityDigest()) || com.alibaba.qwen.code.runtimebroker.ResidentWorkspaceProfile.CAPABILITY_DIGEST.equals( properties.getHarness().getCapabilityDigest())) { @@ -133,7 +137,9 @@ public final class WorkspaceRuntimeResolver { private String extensionDigest(String platform) { if (!managedExtensions.isEnabled()) { - return WorkspaceExecutionProfile.CAPABILITY_DIGEST; + return measuredTools + ? WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST + : WorkspaceExecutionProfile.CAPABILITY_DIGEST; } if ("linux".equals(platform)) { return com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile.LINUX_CAPABILITY_DIGEST; @@ -144,6 +150,22 @@ public final class WorkspaceRuntimeResolver { throw new IllegalStateException("Native extension execution requires an explicit supported platform mode"); } + static boolean matchesRecordedScope(RuntimeScope resolved, RuntimeScope recorded) { + if (resolved.equals(recorded)) { + return true; + } + // Existing ordinary leases retain their attested profile across a measurement rollout. + var ordinary = java.util.Set.of(WorkspaceExecutionProfile.CAPABILITY_DIGEST, + WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST); + return ordinary.contains(resolved.getCapabilityDigest()) + && ordinary.contains(recorded.getCapabilityDigest()) + && resolved.getTenantId().equals(recorded.getTenantId()) + && resolved.getWorkspaceId().equals(recorded.getWorkspaceId()) + && resolved.getWorkspaceGeneration().equals(recorded.getWorkspaceGeneration()) + && resolved.getCanonicalCwd().equals(recorded.getCanonicalCwd()) + && resolved.getIsolationClass().equals(recorded.getIsolationClass()); + } + /** * The W2 settlement probe: proves for the target directory of a cwd * change exactly what a later acquisition would prove for it — the diff --git a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java index b571bf98f12..36c2e9750f3 100644 --- a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java +++ b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransport.java @@ -260,6 +260,63 @@ public final class WorkspaceRuntimeTransport implements RuntimeTransport { return delegate.acknowledgeV2(lease, session, reference); } + @Override + public CompletionStage> executeV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + Map payload, + Map capture) { + requireOwnedWorkspace(lease, session); + if (capture == null && v3(reference)) { + Context original = context(lease, session, true); + Context held = requireLocalCapture(original); + capture = + Map.of( + "tenantId", + session.getScope().getTenantId(), + "sessionId", + session.getHarnessSessionId(), + "turnId", + reference.get("promptId"), + "executionCallId", + reference.get("executionCallId"), + "bindingGeneration", + Long.toString(held.runtime().getGeneration()), + "capturePolicy", + "complete_required"); + } + return delegate.executeV4(lease, session, reference, payload, capture); + } + + @Override + public CompletionStage> statusV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + long afterSequence) { + requireOriginalRuntime(lease, session); + return delegate.statusV4(lease, session, reference, afterSequence); + } + + @Override + public CompletionStage> cancelV4( + RuntimeLease lease, RuntimeSession session, Map reference) { + requireOriginalRuntime(lease, session); + return delegate.cancelV4(lease, session, reference); + } + + @Override + public CompletionStage acknowledgeV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + Map result, + Map receipt) { + requireOriginalRuntime(lease, session); + return delegate.acknowledgeV4(lease, session, reference, result, receipt); + } + @Override public CompletionStage> acknowledgeV3(RuntimeLease lease, RuntimeSession session, Map reference, @@ -575,7 +632,7 @@ public final class WorkspaceRuntimeTransport implements RuntimeTransport { if (authorize) { var resolved = hook ? resolver.resolveHook(session.getHarnessSessionId(), session.getScope().getLifecycleAuthority()) : resolver.resolve(session.getHarnessSessionId(), session.getScope().getLifecycleAuthority()); - if (!resolved.scope().equals(session.getScope())) { + if (!WorkspaceRuntimeResolver.matchesRecordedScope(resolved.scope(), session.getScope())) { throw com.alibaba.qwen.code.managedagent.repository.WorkspaceExecutionStore.unavailable(); } binding = resolved.binding(); diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java index 3c8ca85fe84..b4f7fcdb4d9 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java @@ -1,6 +1,7 @@ package com.alibaba.qwen.code.managedagent.persistence; import com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedActivityDiagnostics; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedHostedRecoveryRecords; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; @@ -275,10 +276,8 @@ final class JournalCommit { } lifecycleAuthorization.authorizeNewLifecycleDispatch(tenantId, sessionId, authority, validated.recordBytes(), reader); - if (diagnosticEvents.stream().anyMatch(event -> event.path("payload").has("diagnosticsRef"))) { - ManagedExtensionRecordStore.require(com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedActivityDiagnostics - .carrierDigest(diagnosticEvents).equals(request.contentDigest()), "Execution diagnostic commit digest conflicts."); - } + ManagedExtensionRecordStore.require(ManagedActivityDiagnostics.matchesCommit(diagnosticEvents, + request.operation(), request.commandId(), request.contentDigest()), "Execution diagnostic commit digest conflicts."); if (authority == null && fencing.lifecycleProtocolFenced(tenantId, sessionId)) { extensionRecords.requireLifecycleSettlement(tenantId, sessionId, validated.recordBytes(), reader); diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java index b4a439992a7..7fb2b52debc 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java @@ -227,7 +227,7 @@ final class JournalWriterLifecycle { JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); - return new WriterGrant(1, JournalStoreRows.epochMillis(initialLeaseUntil), 0, 0, null, 0, false); + return new WriterGrant(1, JournalStoreRows.epochMillis(initialLeaseUntil), 0, 0, null, 0, false, supportedStorage(csiOriginal)); } try { jdbc.update( @@ -247,7 +247,7 @@ final class JournalWriterLifecycle { createdAt.getTime(), createdAt.getTime()); return new WriterGrant(1, JournalStoreRows.epochMillis(initialLeaseUntil), 0, 0, - null, 0, false); + null, 0, false, supportedStorage(csiOriginal)); } catch (DuplicateKeyException ignored) { // A prior acquire created the row; lock and inspect it below. } @@ -285,7 +285,7 @@ final class JournalWriterLifecycle { JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); - return grant(head, leaseUntil, true); + return grant(head, leaseUntil, true, supportedStorage(csiOriginal)); } long generation = JournalStoreRows.increment(head.writerGeneration(), "writer generation"); @@ -323,7 +323,7 @@ final class JournalWriterLifecycle { sessionId); return new WriterGrant(generation, JournalStoreRows.epochMillis(leaseUntil), head.journalRevision(), head.committedSequence(), - head.lastCommitDigest(), head.activationEpoch(), false); + head.lastCommitDigest(), head.activationEpoch(), false, supportedStorage(csiOriginal)); } WriterGrant renewWriter(String tenantId, String sessionId, String writerToken, @@ -398,9 +398,9 @@ final class JournalWriterLifecycle { activation.activationId(), activation.epoch(), activation.renewalSeq(), until); return new WriterGrant(head.writerGeneration(), JournalStoreRows.epochMillis(leaseUntil), head.journalRevision(), head.committedSequence(), head.lastCommitDigest(), head.activationEpoch(), false, - com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedStorageVersions.SUPPORTED, true, lease); + supportedStorage(csiOriginal), true, lease); } - return grant(head, leaseUntil, false); + return grant(head, leaseUntil, false, supportedStorage(csiOriginal)); } SealReceipt sealWriter(String tenantId, String sessionId, String writerToken, @@ -635,10 +635,19 @@ final class JournalWriterLifecycle { } WriterGrant grant(JournalStoreRows.HeadRow head, Timestamp leaseUntil, - boolean replayed) { + boolean replayed, int supportedStorageVersion) { return new WriterGrant(head.writerGeneration(), JournalStoreRows.epochMillis(leaseUntil), head.journalRevision(), head.committedSequence(), - head.lastCommitDigest(), head.activationEpoch(), replayed); + head.lastCommitDigest(), head.activationEpoch(), replayed, supportedStorageVersion); + } + + private static int supportedStorage(com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiFilesRetirementGuard.Original original) { + if (original == null) { + return com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedStorageVersions.SUPPORTED; + } + var scope = original.request().getScope(); + return com.alibaba.qwen.code.runtimebroker.CsiFilesRetirementProfile.hasActivityDiagnostics(scope) ? 4 + : com.alibaba.qwen.code.runtimebroker.CsiFilesRetirementProfile.hasDiagnostics(scope) ? 3 : 1; } static Timestamp plusMillis(Timestamp timestamp, long millis) { diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActivityDiagnosticProjection.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActivityDiagnosticProjection.java index 228c3d4389c..1b5ce4ab1b1 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActivityDiagnosticProjection.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActivityDiagnosticProjection.java @@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.persistence; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedActivityDiagnostics; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements; import com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.ActivityPage; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; import com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys; @@ -66,7 +67,7 @@ final class ManagedActivityDiagnosticProjection { String kind = event.path("kind").asText(); JsonNode payload = event.path("payload"); JsonNode ref = payload.get("diagnosticsRef"); - if (ref == null && !"tool.receipt".equals(kind)) { + if (ref == null && !List.of("tool.receipt", "tool.measured").contains(kind)) { return; } ObjectNode summary = (ObjectNode) state.find(ManagedActivityDiagnostics.STATS, "latest"); @@ -158,6 +159,60 @@ final class ManagedActivityDiagnosticProjection { for (JsonNode reason : evidence.path("coverageReasons")) { ManagedActivityDiagnostics.gap(summary, reason.asText()); } + } else if ("tool.measured".equals(kind)) { + com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords.closed(payload, + java.util.Set.of("executionCallId", "toolOutcomeRef", "measurementsRef"), "tool measurement carrier"); + require(summary != null, "Measured execution is outside activity coverage."); + String execution = payload.path("executionCallId").asText(); + JsonNode link = state.find(ManagedActivityDiagnostics.LINKS, execution); + require(link != null && link.path("observations").size() == 1, "Measured execution has no invocation binding."); + JsonNode binding = link.path("observations").get(0); + JsonNode before = state.find(ManagedActivityDiagnostics.TOOLS, binding.path("invocationId").asText()); + require(before != null, "Measured invocation is outside activity coverage."); + StoredResource measured = resources.apply(payload.path("measurementsRef").path("resourceId").asText()); + requireReference(measured, payload.path("measurementsRef")); + require(ManagedToolMeasurements.KIND.equals(measured.kind()) && measured.schemaVersion() == 1 + && measured.byteLength() <= ManagedToolMeasurements.MAX_BYTES, "Invalid measurement reference."); + JsonNode evidence = readBody(measured); + ManagedToolMeasurements.requireEvidence(evidence); + require(execution.equals(evidence.path("executionCallId").asText()) + && binding.path("invocationId").equals(evidence.path("invocationId")), "Measurement invocation binding conflicts."); + StoredResource outcome = resources.apply(payload.path("toolOutcomeRef").path("resourceId").asText()); + requireReference(outcome, payload.path("toolOutcomeRef")); + require("managed-tool-outcome".equals(outcome.kind()) && outcome.schemaVersion() == 1, "Invalid measured outcome."); + JsonNode outcomeBody = readBody(outcome); + if (outcomeBody.has("executionCallId")) { + require(outcomeBody.path("executionCallId").asText().equals(execution), "Measured outcome belongs to another execution."); + } else if (outcomeBody.path("identity").has("executionCallId")) { + require(outcomeBody.path("identity").path("executionCallId").asText().equals(execution), "Measured capture belongs to another execution."); + } else { + require(outcomeBody.path("history").path("parts").isArray() + && outcomeBody.path("history").path("parts").size() == 1 + && before.path("functionCallId").equals(outcomeBody.path("history").path("parts").get(0).path("functionResponse").path("id")), + "Measured outcome has no original invocation."); + } + JsonNode packet = evidence.path("measurements"); + JsonNode file = packet.path("file"); + if (file.isObject()) { + require("builtin".equals(before.path("toolKind").asText()) && before.path("file").isObject() + && before.path("file").path("operation").equals(file.path("operation")), "File measurement is not a native file invocation."); + } + ObjectNode after = before.deepCopy(); + if (before.has("_measurementEvidenceDigest")) { + require(before.path("_measurementEvidenceDigest").asText().equals(measured.digest()), "Frozen measurement evidence changed."); + } else { + after.put("_measurementEvidenceDigest", measured.digest()); + after.put("_measurementOutcomeKnown", !packet.isNull() && !"unknown".equals(packet.path("executionOutcome").asText())); + after.set("measurements", ManagedToolMeasurements.publicMeasurements(evidence, sequence)); + if (!packet.isNull() && !"unknown".equals(packet.path("executionOutcome").asText())) { + after.set("execution", packet.path("executionOutcome")); + } + } + if (!summary.has("measurementStartSequence")) { + summary.put("measurementStartSequence", Long.toString(sequence)); + } + ManagedActivityDiagnostics.contribute(summary, before, after); + state.invocation(after, sequence, at); } else if (summary != null) { JsonNode link = state.find(ManagedActivityDiagnostics.LINKS, payload.path("executionCallId").asText()); if (link == null) { diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedModelDiagnosticProjection.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedModelDiagnosticProjection.java index 7ea9fd1705c..102e3bcb4af 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedModelDiagnosticProjection.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedModelDiagnosticProjection.java @@ -61,7 +61,11 @@ final class ManagedModelDiagnosticProjection { }); boolean activity = validated.stream().anyMatch(resource -> com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedActivityDiagnostics.KIND.equals(resource.resource().kind())); - return activity ? 4 : diagnostics ? 3 : promotes ? 2 : 1; + boolean measured = new String(records, StandardCharsets.UTF_8).lines().anyMatch(line -> { + JsonNode envelope = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(line); + return envelope != null && "tool.measured".equals(envelope.path("managedSession").path("kind").asText()); + }); + return measured ? 5 : activity ? 4 : diagnostics ? 3 : promotes ? 2 : 1; } static void validateResource(StoredResource resource) { diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeJournalSnapshots.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeJournalSnapshots.java index 1dd58e433a1..fbf73227a8f 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeJournalSnapshots.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeJournalSnapshots.java @@ -413,6 +413,7 @@ public class ManagedNativeJournalSnapshots { case "hosted.batch.planned" -> List.of("planRef"); case "hosted.cleanup" -> List.of("descriptorRef"); case "action.changed" -> List.of("optionsRef", "decisionRef"); + case "tool.measured" -> List.of("toolOutcomeRef", "measurementsRef"); case "tool.receipt" -> List.of("toolOutcomeRef", "resultRef", "resources"); case "checkpoint.committed" -> List.of("stateRef"); case "context.compacted" -> List.of("summaryRef", "tokenCountsRef"); @@ -463,6 +464,11 @@ public class ManagedNativeJournalSnapshots { com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedModelDiagnostics.requireBody( resource.kind(), decode(resource.bytes(), JsonNode.class)); } + if (com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements.KIND.equals(resource.kind())) { + require(resource.schemaVersion() == 1 && resource.byteLength() <= com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements.MAX_BYTES, + "managed_session_snapshot_corrupt"); + com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements.requireEvidence(decode(resource.bytes(), JsonNode.class)); + } if (seen.add(id) && structured(resource.kind())) { JsonNode body = decode(resource.bytes(), JsonNode.class); if (ManagedHostedRecoveryRecords.KINDS.contains(resource.kind()) || "managed-action-options".equals(resource.kind())) { diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStore.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStore.java index dd6e86a8e0a..ba0e44c8db4 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStore.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStore.java @@ -612,7 +612,7 @@ public final class WorkspaceRecoveryStore implements com.alibaba.qwen.code.manag Timestamp lease = (Timestamp) head.get("writer_lease_until"); check(!"ACTIVE".equals(head.get("state")) || lease != null && !lease.after(now(jdbc)), "source_drift"); check(Set.of("ACTIVE", "SEALED", "DELETED").contains(head.get("state")) - && List.of(1, 2, 3, 4).contains(((Number) head.get("storage_version")).intValue()) + && List.of(1, 2, 3, 4, 5).contains(((Number) head.get("storage_version")).intValue()) && ((Number) head.get("compacted_through_revision")).longValue() >= 0 && ((Number) head.get("compacted_through_revision")).longValue() <= ((Number) head.get("journal_revision")).longValue() && "READY".equals(head.get("recovery_status")), "source_drift"); diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/InMemoryToolExecutionRepository.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/InMemoryToolExecutionRepository.java index f4666c6215f..41e4b994770 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/InMemoryToolExecutionRepository.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/InMemoryToolExecutionRepository.java @@ -256,6 +256,23 @@ public final class InMemoryToolExecutionRepository return settled; } + @Override + public synchronized ToolExecutionRecord attachMeasurements( + ToolExecutionRecord expected, Map metadata) { + ToolExecutionRecord current = recordsById.get(expected.getExecutionCallId()); + if (current == null + || !current.sameIdentity(expected) + || !current.sameAuthorization(expected) + || current.getVersion() != expected.getVersion() + || !current.isSettled()) { + return null; + } + ToolExecutionRecord updated = + current.withMeasurements(metadata).withVersion(current.getVersion() + 1); + recordsById.put(updated.getExecutionCallId(), updated); + return updated; + } + @Override public synchronized ToolExecutionRecord resolveUnknown( ToolExecutionRecord expected, diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionObservations.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionObservations.java new file mode 100644 index 00000000000..6b39bd583b8 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionObservations.java @@ -0,0 +1,68 @@ +package com.alibaba.qwen.code.runtimebroker.persistence; + +import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; + +import javax.sql.DataSource; + +import java.time.Instant; +import java.util.Map; + +import static com.alibaba.qwen.code.runtimebroker.persistence.JdbcToolExecutionRepository.selectByExecutionId; +import static com.alibaba.qwen.code.runtimebroker.persistence.JdbcToolExecutionRepository.updateExecution; + +final class JdbcToolExecutionObservations { + private JdbcToolExecutionObservations() { } + + static ToolExecutionRecord attachMeasurements( + DataSource dataSource, ToolExecutionRecord expected, Map metadata) { + return JdbcRepositorySupport.transaction( + dataSource, + connection -> { + JdbcCsiActivationAdmission.refuseUnqualifiedWriter( + connection, expected.getBindingId()); + ToolExecutionRecord current = + selectByExecutionId(connection, expected.getExecutionCallId(), true); + if (current == null + || !current.sameIdentity(expected) + || !current.sameAuthorization(expected) + || current.getVersion() != expected.getVersion() + || !current.isSettled()) { + return null; + } + ToolExecutionRecord updated = + current.withMeasurements(metadata) + .withVersion(current.getVersion() + 1); + updateExecution(connection, updated); + return updated; + }); + } + + static ToolExecutionRecord resolve(DataSource dataSource, ToolExecutionRecord expected, + Map resolutionResult, Instant resolutionTime, + boolean unknownOnly) { + if (expected == null) { + throw new IllegalArgumentException("expected is required"); + } + return JdbcRepositorySupport.transaction( + dataSource, + connection -> { + JdbcCsiActivationAdmission.refuseUnqualifiedWriter( + connection, expected.getBindingId()); + ToolExecutionRecord current = + selectByExecutionId(connection, expected.getExecutionCallId(), true); + if (current == null + || !current.sameIdentity(expected) + || current.getVersion() != expected.getVersion() + || !current.needsReconciliation() + || unknownOnly + && current.getState() != ToolExecutionRecord.State.UNKNOWN) { + return null; + } + ToolExecutionRecord resolved = + current.resolveUnsettled(resolutionResult, resolutionTime) + .withVersion(current.getVersion() + 1); + updateExecution(connection, resolved); + return resolved; + }); + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionRepository.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionRepository.java index a05ca170657..3d31dcfd168 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionRepository.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/persistence/JdbcToolExecutionRepository.java @@ -557,42 +557,19 @@ public final class JdbcToolExecutionRepository @Override public ToolExecutionRecord resolveUnknown(ToolExecutionRecord expected, Map resolutionResult, Instant resolutionTime) { - return resolve(expected, resolutionResult, resolutionTime, true); + return JdbcToolExecutionObservations.resolve(dataSource, expected, resolutionResult, resolutionTime, true); + } + + @Override + public ToolExecutionRecord attachMeasurements( + ToolExecutionRecord expected, Map metadata) { + return JdbcToolExecutionObservations.attachMeasurements(dataSource, expected, metadata); } @Override public ToolExecutionRecord resolveUnsettled(ToolExecutionRecord expected, Map resolutionResult, Instant resolutionTime) { - return resolve(expected, resolutionResult, resolutionTime, false); - } - - private ToolExecutionRecord resolve(ToolExecutionRecord expected, - Map resolutionResult, Instant resolutionTime, - boolean unknownOnly) { - if (expected == null) { - throw new IllegalArgumentException("expected is required"); - } - return JdbcRepositorySupport.transaction( - dataSource, - connection -> { - JdbcCsiActivationAdmission.refuseUnqualifiedWriter( - connection, expected.getBindingId()); - ToolExecutionRecord current = - selectByExecutionId(connection, expected.getExecutionCallId(), true); - if (current == null - || !current.sameIdentity(expected) - || current.getVersion() != expected.getVersion() - || !current.needsReconciliation() - || unknownOnly - && current.getState() != ToolExecutionRecord.State.UNKNOWN) { - return null; - } - ToolExecutionRecord resolved = - current.resolveUnsettled(resolutionResult, resolutionTime) - .withVersion(current.getVersion() + 1); - updateExecution(connection, resolved); - return resolved; - }); + return JdbcToolExecutionObservations.resolve(dataSource, expected, resolutionResult, resolutionTime, false); } private CsiContinuation lockCsiContinuation(Connection connection, ToolExecutionRecord hint) @@ -1041,7 +1018,7 @@ public final class JdbcToolExecutionRepository return false; } - private static ToolExecutionRecord selectByExecutionId( + static ToolExecutionRecord selectByExecutionId( Connection connection, String executionCallId, boolean forUpdate) throws SQLException { String sql = "SELECT " + EXECUTION_COLUMNS @@ -1078,7 +1055,7 @@ public final class JdbcToolExecutionRepository } } - private static void updateExecution(Connection connection, + static void updateExecution(Connection connection, ToolExecutionRecord record) throws SQLException { String sql = "UPDATE ma_tool_execution SET execution_state = ?, " + "execution_status = ?, result_json = ?, " diff --git a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerService.java b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerService.java index 8dba77ca4e1..3df8ed77527 100644 --- a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerService.java +++ b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerService.java @@ -28,6 +28,7 @@ import com.alibaba.qwen.code.runtimebroker.RuntimeScope; import com.alibaba.qwen.code.runtimebroker.RuntimeSession; import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements; import com.alibaba.qwen.code.runtimebroker.RuntimeTransport; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; @@ -77,6 +78,7 @@ import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.i import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.mapFailure; import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.mapStoreFailure; import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.notFound; +import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.prepareExecutionReference; import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.referenceString; import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.renewalDelayMillis; import static com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerSupport.requireOpen; @@ -283,7 +285,7 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO publicationVerifier, this.brokerOwnerId, this.dispatchLeaseDuration, this.v3ResultWindow, clock, scheduler, renewalScheduler, closed); this.executionReconciler = new RuntimeExecutionReconciler(bindingRegistry, executionState, executionRepository, - provisioner, transport, publicationVerifier, this.operationLeaseDuration, clock, closed, + provisioner, transport, publicationVerifier, this.operationLeaseDuration, clock, scheduler, closed, new ExecutionObservationCooldown(clock, scheduler)); this.executionRecovery = new RuntimeExecutionRecovery(bindingRegistry, executionState, executionRepository, sessionRepository, transport, executionReconciler, closed); @@ -1511,46 +1513,8 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO String payloadDigest, String publicationId) { requireOpen(closed); String key = BrokerValues.requireId(idempotencyKey, "idempotencyKey"); - Map immutable = immutableMap(reference, "reference"); - if (ProviderRuntimeProtocol.isReference(immutable)) { - ProviderRuntimeProtocol.reference(immutable, runtimeSessionId); - } else { - boolean v3 = Integer.valueOf(3).equals(immutable.get("runtimeProtocol")); - if (v3 && payloadDigest != null) { - throw invalid("runtime_reference_invalid", "Tool v3 publication modes conflict"); - } - Set fields = v3 - ? Set.of("sessionId", "promptId", "callId", "argsDigest", "runtimeProtocol", "inputDigest") - : Set.of("sessionId", "promptId", "callId", "argsDigest"); - if (!immutable.keySet().equals(fields) - || !(immutable.get("argsDigest") instanceof String digest) - || !digest.matches("sha256:[0-9a-f]{64}") - || v3 && (!(immutable.get("inputDigest") instanceof String inputDigest) - || !inputDigest.matches("[0-9a-f]{64}"))) { - throw invalid("runtime_reference_invalid", "Deferred execution reference is invalid"); - } - Map deferred = new LinkedHashMap<>(immutable); - if (payloadDigest == null) { - deferred.put("dispatchMode", "deferred"); - } else { - if (!payloadDigest.matches("sha256:[0-9a-f]{64}") - || publicationId == null - || !publicationId.matches("[a-z0-9_-]{1,128}")) { - throw invalid("runtime_reference_invalid", "Payload digest or publication ID is invalid"); - } - deferred.put("dispatchMode", "deferred_v3"); - deferred.put("payloadDigest", payloadDigest); - deferred.put("publicationId", publicationId); - } - if (deferred.containsValue(null)) { - // immutableMap admits null values; keep the rejection on the - // stage channel as a 400 rather than a synchronous NPE. - return CompletableFuture.failedFuture(invalid("runtime_reference_invalid", - "Deferred execution reference is invalid")); - } - immutable = Map.copyOf(deferred); - } - return createExecutionReceipt(harnessSessionId, runtimeSessionId, key, immutable, false); + return prepareExecutionReference(runtimeSessionId, reference, payloadDigest, publicationId) + .thenCompose(immutable -> createExecutionReceipt(harnessSessionId, runtimeSessionId, key, immutable, false)); } public CompletionStage startExecution( @@ -1573,6 +1537,9 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO try { bindingRegistry.requireReadySessionRecord(context); ToolExecutionRecord record = executionState.requireExecution(context, executionId); + if (RuntimeToolMeasurements.selected(record.getReference())) { + RuntimeToolMeasurements.requireProfile(context.session()); + } boolean v3 = "deferred_v3".equals(record.getReference().get("dispatchMode")); if (!"deferred".equals(record.getReference().get("dispatchMode")) && !v3) { throw conflict("runtime_execution_conflict", "Execution was not reserved for deferred dispatch"); @@ -1687,7 +1654,8 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO } }); return mapStoreFailure(executionState.unknownWhenAdmissionClosed(started, - harnessSessionId, runtimeSessionId, executionId)); + harnessSessionId, runtimeSessionId, executionId) + .thenCompose(record -> executionReconciler.measuredResult(record, 0))); } public CompletionStage startExecution( @@ -1737,7 +1705,7 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO throw conflict("runtime_idempotency_conflict", "idempotency key belongs to another request"); } - return CompletableFuture.completedFuture(receipt); + return executionReconciler.measuredResult(receipt, 0); } return bindingRegistry.requireReadySession(harnessSessionId, runtimeSessionId) .thenApply(context -> createExecution(context, key, reference, dispatch)); @@ -1754,7 +1722,7 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO ToolExecutionRecord saved = executionState.requireOwnedExecution(harnessSessionId, runtimeSessionId, executionId); if (saved.isTerminal()) { - return CompletableFuture.completedFuture(saved); + return executionReconciler.measuredResult(saved, 0); } if ("deferred_v3".equals(saved.getReference().get("dispatchMode"))) { if (saved.getState() == ToolExecutionRecord.State.UNKNOWN @@ -1838,7 +1806,9 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO } return safeStage(() -> { bindingRegistry.requireUsableLease(context); - return transport.acknowledge(context.lease(), context.session(), record.getReference(), savedReceipt); + return RuntimeToolMeasurements.selected(record.getReference()) + ? executionReconciler.acknowledgeMeasured(context, record, savedReceipt) + : transport.acknowledge(context.lease(), context.session(), record.getReference(), savedReceipt); }).thenApply(status -> { if (!"settled".equals(status.get("state"))) { throw conflict("runtime_execution_conflict", "Runtime did not confirm the result acknowledgement"); @@ -1892,7 +1862,9 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO "Session receipt conflicts with publication"); } bindingRegistry.requireUsableLease(context); - return transport.acknowledgeV3(context.lease(), + return RuntimeToolMeasurements.selected(original.getReference()) + ? executionReconciler.acknowledgeMeasured(context, original, receipt) + : transport.acknowledgeV3(context.lease(), context.session(), original.getReference(), receipt); }), "runtime_execution_ack_failed", "Tool v3 acknowledgement failed") @@ -1907,7 +1879,9 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO throw conflict("runtime_execution_conflict", "Session receipt conflicts with publication"); } bindingRegistry.requireUsableLease(context); - return transport.acknowledgeV3(context.lease(), context.session(), + return RuntimeToolMeasurements.selected(original.getReference()) + ? executionReconciler.acknowledgeMeasured(context, original, saved) + : transport.acknowledgeV3(context.lease(), context.session(), original.getReference(), saved); }), "runtime_execution_ack_failed", "Tool v3 acknowledgement failed") .whenComplete((ignored, error) -> context.endControl()); diff --git a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerSupport.java b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerSupport.java index 02462e9ac28..691428141f1 100644 --- a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerSupport.java +++ b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeBrokerSupport.java @@ -6,12 +6,14 @@ import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority; import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements; import com.alibaba.qwen.code.runtimebroker.StoreFailureException; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; import com.alibaba.qwen.code.runtimebroker.protocol.ProviderRuntimeProtocol; import com.alibaba.qwen.code.runtimebroker.protocol.RuntimeProviderKinds; import java.time.Duration; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -31,6 +33,54 @@ final class RuntimeBrokerSupport { "unknown"); static final int MAX_CAS_ATTEMPTS = 16; + static CompletionStage> prepareExecutionReference(String runtimeSessionId, + Map reference, String payloadDigest, String publicationId) { + Map immutable = immutableMap(reference, "reference"); + if (ProviderRuntimeProtocol.isReference(immutable)) { + ProviderRuntimeProtocol.reference(immutable, runtimeSessionId); + } else { + boolean measured = RuntimeToolMeasurements.selected(immutable); + boolean v3 = Integer.valueOf(3).equals(immutable.get("runtimeProtocol")); + if (v3 && payloadDigest != null) { + throw invalid("runtime_reference_invalid", "Tool v3 publication modes conflict"); + } + Set fields = new HashSet<>(v3 + ? Set.of("sessionId", "promptId", "callId", "argsDigest", "runtimeProtocol", "inputDigest") + : Set.of("sessionId", "promptId", "callId", "argsDigest")); + if (measured) { + fields.add("resultProtocol"); + } + if (!immutable.keySet().equals(fields) + || !(immutable.get("argsDigest") instanceof String digest) + || !digest.matches("sha256:[0-9a-f]{64}") + || v3 && (!(immutable.get("inputDigest") instanceof String inputDigest) + || !inputDigest.matches("[0-9a-f]{64}"))) { + throw invalid("runtime_reference_invalid", "Deferred execution reference is invalid"); + } + Map deferred = new LinkedHashMap<>(immutable); + if (payloadDigest == null) { + deferred.put("dispatchMode", "deferred"); + } else { + if (!payloadDigest.matches("sha256:[0-9a-f]{64}") + || publicationId == null + || !publicationId.matches("[a-z0-9_-]{1,128}")) { + throw invalid("runtime_reference_invalid", "Payload digest or publication ID is invalid"); + } + deferred.put("dispatchMode", "deferred_v3"); + deferred.put("payloadDigest", payloadDigest); + deferred.put("publicationId", publicationId); + } + if (deferred.containsValue(null)) { + // immutableMap admits null values; keep the rejection on the + // stage channel as a 400 rather than a synchronous NPE. + return CompletableFuture.failedFuture(invalid("runtime_reference_invalid", + "Deferred execution reference is invalid")); + } + immutable = Map.copyOf(deferred); + } + return CompletableFuture.completedFuture(immutable); + } + static CompletionStage safeStage( Supplier> supplier) { try { diff --git a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionCancellation.java b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionCancellation.java index b8cab0e54f6..380b609b8a4 100644 --- a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionCancellation.java +++ b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionCancellation.java @@ -5,6 +5,7 @@ import com.alibaba.qwen.code.runtimebroker.CsiFilesRetirementProfile; import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRecord; +import com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements; import com.alibaba.qwen.code.runtimebroker.RuntimeTransport; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; @@ -191,7 +192,10 @@ final class RuntimeExecutionCancellation { } bindingRegistry.requireUsableLease(context); return mapFailure(safeStage(() -> - publicationV3 + RuntimeToolMeasurements.selected(requested.getReference()) + ? transport.cancelV4(context.lease(), context.session(), + requested.getReference()) + : publicationV3 ? transport.cancelV3(context.lease(), context.session(), requested.getReference()).thenApply( RuntimeBrokerSupport::projectV3Status) diff --git a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcher.java b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcher.java index d35d54479b2..85aee532a16 100644 --- a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcher.java +++ b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcher.java @@ -11,6 +11,7 @@ import com.alibaba.qwen.code.runtimebroker.RuntimePublicationVerifier; import com.alibaba.qwen.code.runtimebroker.RuntimeScope; import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements; import com.alibaba.qwen.code.runtimebroker.RuntimeTransport; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; @@ -191,7 +192,22 @@ final class RuntimeExecutionDispatcher { throw exception; } invocations.add(executing.getExecutionCallId()); - CompletionStage> invocation = grant == null + CompletionStage> invocation = + RuntimeToolMeasurements.selected(executing.getReference()) + ? safeStage(() -> transport.executeV4( + context.lease(), context.session(), + dispatchReference(executing, context.binding().getRequest().getScope()), + payload, grant == null ? null : capture(grant))) + .thenCompose(answer -> { + if ("settled".equals(answer.get("state")) + && answer.get("result") instanceof Map raw) { + @SuppressWarnings("unchecked") + Map result = (Map) raw; + return CompletableFuture.completedFuture(result); + } + return awaitV3Result(context, executing, clock.instant().plus(v3ResultWindow)); + }) + : grant == null ? safeStage(() -> payload == null ? transport.execute(context.lease(), context.session(), executing.getReference()) : transport.execute(context.lease(), context.session(), @@ -235,7 +251,8 @@ final class RuntimeExecutionDispatcher { } return null; }).thenCompose(ignored -> { - if (!RuntimeProviderKinds.SSH_PROCESS.equals(context.binding().getRequest().getProvisionerKind())) { + if (!RuntimeProviderKinds.SSH_PROCESS.equals(context.binding().getRequest().getProvisionerKind()) + && !RuntimeToolMeasurements.selected(executing.getReference())) { return CompletableFuture.completedFuture(null); } ToolExecutionRecord settled = executionRepository.findByExecutionCallId(executing.getExecutionCallId()); @@ -279,8 +296,12 @@ final class RuntimeExecutionDispatcher { return; } try { - Map finished = publicationVerifier.finished(original); - if (finished != null) { + boolean measured = + RuntimeToolMeasurements.selected( + original.getReference()); + Map finished = + publicationVerifier == null ? null : publicationVerifier.finished(original); + if (finished != null && !measured) { answer.complete(finished); return; } @@ -292,8 +313,10 @@ final class RuntimeExecutionDispatcher { || !provisioner.isUsable(context.lease()) || !clock.instant().isBefore(deadline)) { throw evidenceUnavailable(); } - safeStage(() -> transport.statusV3(context.lease(), context.session(), - original.getReference(), 0)).whenComplete((status, error) -> { + safeStage(() -> measured + ? transport.statusV4(context.lease(), context.session(), original.getReference(), 0) + : transport.statusV3(context.lease(), context.session(), original.getReference(), 0)) + .whenComplete((status, error) -> { if (answer.isDone()) { return; } @@ -301,6 +324,14 @@ final class RuntimeExecutionDispatcher { answer.completeExceptionally(unwrap(error)); return; } + if (measured && error == null && status != null + && "settled".equals(status.get("state")) + && status.get("result") instanceof Map raw) { + @SuppressWarnings("unchecked") + Map result = (Map) raw; + answer.complete(result); + return; + } if (error == null && status != null && "settled".equals(status.get("state")) && status.get("result") instanceof Map result && "not_started".equals(result.get("executionStatus"))) { @@ -398,7 +429,8 @@ final class RuntimeExecutionDispatcher { } CompletionStage acknowledgePersistedTools(RuntimeBindingRecord binding, ToolExecutionRecord execution) { - if (!RuntimeProviderKinds.SSH_PROCESS.equals(binding.getRequest().getProvisionerKind()) + if ((!RuntimeProviderKinds.SSH_PROCESS.equals(binding.getRequest().getProvisionerKind()) + && !RuntimeToolMeasurements.selected(execution.getReference())) || !execution.isSettled() || execution.getResult() == null || ProviderRuntimeProtocol.isReference(execution.getReference())) { return CompletableFuture.completedFuture(null); @@ -414,7 +446,8 @@ final class RuntimeExecutionDispatcher { if (binding.getLease() == null || session == null || !execution.belongsTo(session) || !binding.getBindingId().equals(execution.getBindingId()) || binding.getGeneration() != execution.getRuntimeGeneration() - || !binding.getRequest().getIsolationKey().equals(execution.getHarnessSessionId())) { + || !com.alibaba.qwen.code.runtimebroker.HarnessOwnership.sharesRuntimeSessions(binding.getRequest()) + && !execution.getHarnessSessionId().equals(binding.getRequest().getIsolationKey())) { return failed(conflict("workspace_close_identity_unverified", "Persisted Tool ownership conflicts")); } if (noStart != null) { @@ -427,7 +460,15 @@ final class RuntimeExecutionDispatcher { } }); } - return transport.acknowledgeV2(binding.getLease(), session.getSession(), execution.getReference()); + return RuntimeToolMeasurements.selected( + execution.getReference()) + ? transport.acknowledgeV4( + binding.getLease(), + session.getSession(), + execution.getReference(), + execution.getResult(), + null) + : transport.acknowledgeV2(binding.getLease(), session.getSession(), execution.getReference()); } private static boolean ownsDispatch(ToolExecutionRecord current, diff --git a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconciler.java b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconciler.java index 7f1821d76f7..20889e156cf 100644 --- a/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconciler.java +++ b/packages/managed-agent-server/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconciler.java @@ -5,6 +5,7 @@ import com.alibaba.qwen.code.runtimebroker.ExecutionReconciliation; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.RuntimeProvisioner; import com.alibaba.qwen.code.runtimebroker.RuntimePublicationVerifier; +import com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements; import com.alibaba.qwen.code.runtimebroker.RuntimeTransport; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; @@ -12,6 +13,7 @@ import com.alibaba.qwen.code.runtimebroker.service.RuntimeBindingRegistry.Sessio import java.time.Clock; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -19,6 +21,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -43,6 +46,7 @@ final class RuntimeExecutionReconciler { private final RuntimePublicationVerifier publicationVerifier; private final Duration operationLeaseDuration; private final Clock clock; + private final ScheduledExecutorService scheduler; private final AtomicBoolean closed; private final ExecutionObservationCooldown observationCooldown; @@ -54,6 +58,7 @@ final class RuntimeExecutionReconciler { RuntimePublicationVerifier publicationVerifier, Duration operationLeaseDuration, Clock clock, + ScheduledExecutorService scheduler, AtomicBoolean closed, ExecutionObservationCooldown observationCooldown) { this.bindingRegistry = bindingRegistry; @@ -64,6 +69,7 @@ final class RuntimeExecutionReconciler { this.publicationVerifier = publicationVerifier; this.operationLeaseDuration = operationLeaseDuration; this.clock = clock; + this.scheduler = scheduler; this.closed = closed; this.observationCooldown = observationCooldown; } @@ -77,6 +83,74 @@ final class RuntimeExecutionReconciler { static final Set RUNTIME_STATUS_FIELDS = Set.of( "state", "result"); + CompletionStage measuredResult(ToolExecutionRecord original, int attempt) { + if (!original.isSettled() || !RuntimeToolMeasurements.selected(original.getReference()) + || original.getResult().containsKey(RuntimeToolMeasurements.FIELD) || attempt >= 100) { + return CompletableFuture.completedFuture(original); + } + requireOpen(closed); + return bindingRegistry.requireReadySession(original.getHarnessSessionId(), original.getRuntimeSessionId()).thenCompose(context -> { + ToolExecutionRecord current = executionState.requireExecution(context, original.getExecutionCallId()); + if (current.getResult().containsKey(RuntimeToolMeasurements.FIELD)) { + return CompletableFuture.completedFuture(current); + } + bindingRegistry.requireUsableLease(context); + return transport.statusV4(context.lease(), context.session(), current.getReference(), 0).thenCompose(status -> { + if ("settled".equals(status.get("state")) && status.get("result") instanceof Map raw) { + @SuppressWarnings("unchecked") + Map observed = (Map) raw; + Map before = new LinkedHashMap<>(RuntimeToolMeasurements.base(current.getResult())); + Map after = new LinkedHashMap<>(RuntimeToolMeasurements.base(observed)); + normalizeCaptureDelivery(before); + normalizeCaptureDelivery(after); + if (!BrokerValues.sameJsonMap(before, after)) { + throw conflict("runtime_execution_conflict", "Original measured tool result changed"); + } + ToolExecutionRecord updated = executionRepository.attachMeasurements(current, RuntimeToolMeasurements.metadata(observed)); + return updated != null ? CompletableFuture.completedFuture(updated) + : measuredResult(executionState.requireExecution(context, current.getExecutionCallId()), attempt + 1); + } + if ("unknown".equals(status.get("state"))) { + return CompletableFuture.completedFuture(current); + } + CompletableFuture next = new CompletableFuture<>(); + next.orTimeout(operationLeaseDuration.toMillis(), TimeUnit.MILLISECONDS); + scheduler.schedule(() -> safeStage(() -> measuredResult(current, attempt + 1)).whenComplete((value, failure) -> { + if (failure == null) { + next.complete(value); + } else { + next.completeExceptionally(failure); + } + }), 50, TimeUnit.MILLISECONDS); + return next; + }); + }).exceptionallyCompose(failure -> { + Throwable cause = unwrap(failure); + if (cause instanceof RuntimeBrokerException problem && (problem.getStatusCode() == 404 || problem.getStatusCode() == 503)) { + return CompletableFuture.completedFuture(original); + } + return CompletableFuture.failedFuture(cause); + }); + } + + private static void normalizeCaptureDelivery(Map result) { + if (result.get("capture") instanceof Map capture) { + Map copy = new LinkedHashMap<>(); + capture.forEach((key, value) -> { + if (!"deliveryStatus".equals(key)) { + copy.put((String) key, value); + } + }); + result.put("capture", copy); + } + } + + CompletionStage> acknowledgeMeasured(SessionContext context, ToolExecutionRecord original, + Map receipt) { + return measuredResult(original, 0).thenCompose(record -> transport.acknowledgeV4(context.lease(), context.session(), + record.getReference(), record.getResult(), receipt)).thenApply(ignored -> Map.of("state", "settled")); + } + /** * Asks the original Runtime once whether an {@code UNKNOWN} execution * settled, and settles the record only on that evidence. Any other @@ -242,7 +316,11 @@ final class RuntimeExecutionReconciler { new CompletableFuture<>(); try { CompletionStage> query = safeStage(() -> - "deferred_v3".equals(unknown.getReference().get("dispatchMode")) + RuntimeToolMeasurements.selected(unknown.getReference()) + ? transport.statusV4(context.lease(), context.session(), + unknown.getReference(), unknown.getLastSequence()) + .thenApply(RuntimeBrokerSupport::projectV3Status) + : "deferred_v3".equals(unknown.getReference().get("dispatchMode")) ? transport.statusV3(context.lease(), context.session(), unknown.getReference(), unknown.getLastSequence()) .thenApply(RuntimeBrokerSupport::projectV3Status) diff --git a/packages/managed-agent-server/runtime-external/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisioner.java b/packages/managed-agent-server/runtime-external/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisioner.java index 25606635460..53620abfab4 100644 --- a/packages/managed-agent-server/runtime-external/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisioner.java +++ b/packages/managed-agent-server/runtime-external/src/main/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisioner.java @@ -77,7 +77,8 @@ public final class ExternalWorkspaceProvisioner extends ExternalRuntimeProvision */ @Override public RuntimeProvisionRequest createRequest(RuntimeScope scope, String isolationKey) { - if (!WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(scope.getCapabilityDigest())) { + if (!WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(scope.getCapabilityDigest()) + && !WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST.equals(scope.getCapabilityDigest())) { return super.createRequest(scope, isolationKey); } return new RuntimeProvisionRequest(scope, isolationKey, KIND, scope.getWorkspaceId()); @@ -194,8 +195,8 @@ public final class ExternalWorkspaceProvisioner extends ExternalRuntimeProvision || !request.getScope().getWorkspaceId().equals(storage.tenantWorkspaceId()) || !provider.equals(storage.providerId()) || !request.getScope().getCanonicalCwd().equals(storage.mountRoot()) - || !WorkspaceExecutionProfile.CAPABILITY_DIGEST - .equals(request.getScope().getCapabilityDigest())) { + || (!WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(request.getScope().getCapabilityDigest()) + && !WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST.equals(request.getScope().getCapabilityDigest()))) { throw refuse("external_storage_registration_mismatch", "The external storage registration does not match this placement."); } diff --git a/packages/managed-agent-server/runtime-http/src/main/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeTransport.java b/packages/managed-agent-server/runtime-http/src/main/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeTransport.java index 928a6582aa7..f4f3fca86e6 100644 --- a/packages/managed-agent-server/runtime-http/src/main/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeTransport.java +++ b/packages/managed-agent-server/runtime-http/src/main/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeTransport.java @@ -534,7 +534,10 @@ public final class HttpRuntimeTransport implements WorkspacePhysicalTransport { body.put("contextDigest", binding.getContextDigest()); body.put("contextConfigRef", binding.getContextConfigRef()); body.put("profile", ResidentWorkspaceProfile.selects(request) - ? ResidentWorkspaceProfile.PROFILE : WorkspaceExecutionProfile.PROFILE); + ? ResidentWorkspaceProfile.PROFILE : WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST.equals( + session.getScope().getCapabilityDigest()) + ? WorkspaceExecutionProfile.MEASURED_PROFILE + : WorkspaceExecutionProfile.PROFILE); Map expected = new LinkedHashMap<>(body); expected.put("runtimeInstanceId", seed.getProvisionalRuntimeId()); expected.put("runtimeIncarnation", seed.getGatewayIncarnation()); @@ -812,6 +815,207 @@ public final class HttpRuntimeTransport implements WorkspacePhysicalTransport { .thenApply(bytes -> parseV3Response(bytes, "acknowledge")); } + @Override + public CompletionStage> executeV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + Map payload, + Map capture) { + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.requireProfile(session); + Map merged = new LinkedHashMap<>(reference); + if (payload != null) { + merged.putAll(payload); + } + Map body = v4Body(reference); + body.put("toolName", referenceToolName(merged)); + Object input = referenceInput(merged); + ManagedExtensionProtocol.requireBoundInput(session, input); + body.put("input", input); + if (capture != null) { + body.put("capture", capture); + } + return post( + lease, + "/internal/managed-runtime/v4/execute", + encodeToolRequest(body, TOOL_REQUEST_LIMIT_BYTES), + TOOL_RESULT_LIMIT_BYTES + 4096) + .thenApply( + bytes -> + parseV4Response( + bytes, + "execute", + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements + .executionProtocol(reference))); + } + + @Override + public CompletionStage> statusV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + long afterSequence) { + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.requireProfile(session); + if (afterSequence < 0) { + throw new IllegalArgumentException("Invalid sequence."); + } + Map body = v4Body(reference); + body.put("afterSequence", afterSequence); + return post( + lease, + "/internal/managed-runtime/v4/status", + encodeToolRequest(body, BODY_LIMIT_BYTES), + TOOL_RESULT_LIMIT_BYTES + 4096) + .thenApply( + bytes -> + parseV4Response( + bytes, + "status", + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements + .executionProtocol(reference))); + } + + @Override + public CompletionStage> cancelV4( + RuntimeLease lease, RuntimeSession session, Map reference) { + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.requireProfile(session); + return post( + lease, + "/internal/managed-runtime/v4/cancel", + encodeToolRequest(v4Body(reference), BODY_LIMIT_BYTES), + TOOL_RESULT_LIMIT_BYTES + 4096) + .thenApply( + bytes -> + parseV4Response( + bytes, + "cancel", + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements + .executionProtocol(reference))); + } + + @Override + public CompletionStage acknowledgeV4( + RuntimeLease lease, + RuntimeSession session, + Map reference, + Map result, + Map receipt) { + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.requireProfile(session); + Map body = v4Body(reference); + body.put( + "resultDigest", + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.metadata(result) + .get("resultDigest")); + if (com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.executionProtocol(reference) + == 3) { + body.put("receipt", receipt); + } + return post( + lease, + "/internal/managed-runtime/v4/acknowledge", + encodeToolRequest(body, BODY_LIMIT_BYTES), + TOOL_RESULT_LIMIT_BYTES + 4096) + .thenAccept( + bytes -> { + Map answer = + parseV4Response( + bytes, + "acknowledge", + com.alibaba.qwen.code.runtimebroker + .RuntimeToolMeasurements.executionProtocol( + reference)); + if (!Set.of("acknowledged", "settled", "unknown") + .contains(answer.get("state"))) { + throw protocol( + "Runtime did not acknowledge its original measured" + + " result."); + } + }); + } + + private static Map v4Body(Map reference) { + if (!com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.selected(reference)) { + throw new IllegalArgumentException( + "Measured protocol must be reserved before dispatch."); + } + Map body = new LinkedHashMap<>(); + body.put("protocolVersion", 4); + body.put( + "executionProtocol", + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.executionProtocol( + reference)); + Map legacy = new LinkedHashMap<>(reference); + legacy.remove("resultProtocol"); + legacy.remove("runtimeProtocol"); + legacy.remove("inputDigest"); + legacy.remove("executionCallId"); + body.put("reference", referenceIdentity(legacy)); + return body; + } + + private static Map parseV4Response( + byte[] bytes, String operation, int executionProtocol) { + Map fields = ManagedContextProtocol.parse(bytes); + if (!Set.of( + "protocolVersion", + "executionProtocol", + "state", + "lastSequence", + "result", + "measurements", + "resultDigest") + .containsAll(fields.keySet()) + || !Integer.valueOf(4).equals(fields.get("protocolVersion")) + || !Integer.valueOf(executionProtocol).equals(fields.get("executionProtocol")) + || !(fields.get("state") instanceof String state) + || !TOOL_STATES.contains(state)) { + throw protocol("Invalid measured Runtime response."); + } + Map legacy = new LinkedHashMap<>(fields); + if ("acknowledged".equals(state)) { + if (fields.containsKey("result") + || fields.containsKey("measurements") + || fields.containsKey("resultDigest")) { + throw protocol("Acknowledged result still contains payload."); + } + return Map.of("state", state); + } + legacy.keySet().removeAll(Set.of("executionProtocol", "measurements", "resultDigest")); + legacy.put("protocolVersion", executionProtocol); + if (!"status".equals(operation)) { + legacy.remove("lastSequence"); + } + if (executionProtocol == 3) { + legacy.put("toolResult", "managed-tool-result/1"); + parseV3Response(JsonCodec.encode(legacy), operation); + } else { + parseToolResponse(JsonCodec.encode(legacy), operation); + } + if (!"settled".equals(state)) { + if (fields.containsKey("measurements") || fields.containsKey("resultDigest")) { + throw protocol("Unsettled response contains a measurement."); + } + return fields.containsKey("lastSequence") + ? Map.of("state", state, "lastSequence", fields.get("lastSequence")) + : Map.of("state", state); + } + if (!(fields.get("result") instanceof Map raw) + || !fields.containsKey("measurements")) { + throw protocol("Settled measured result is incomplete."); + } + @SuppressWarnings("unchecked") + Map result = (Map) raw; + return Map.of( + "state", + state, + "result", + com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements.attach( + executionProtocol, + result, + fields.get("measurements"), + fields.get("resultDigest"))); + } + private static void requireV3Context(RuntimeLease lease, RuntimeSession session) { if (lease == null || session == null) { @@ -1528,6 +1732,10 @@ public final class HttpRuntimeTransport implements WorkspacePhysicalTransport { private static RuntimeBrokerException contextFailure( HttpResponse response, BoundedBody body, String path) { + if (response.statusCode() == 501 && path.startsWith("/internal/managed-runtime/v4/")) { + return error(501, "runtime_tool_v4_unsupported", + "Managed Runtime does not support Tool v4.", false); + } if (response.statusCode() == 501 && (V3_EXECUTE_PATH.equals(path) || V3_STATUS_PATH.equals(path))) { return error(501, "runtime_tool_v3_unsupported", diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java index e12f69722b2..0982626da7c 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java @@ -871,6 +871,7 @@ public class ManagedAgentProperties private boolean localDirectoryImportEnabled; private String isolationClass = "session"; private String boundSessionIsolationClass = "session"; + private boolean toolMeasurements; private boolean sessionCreateWarm; private String stateDirectory = ""; private boolean durableLocalProcess = true; diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersion.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersion.java index f1b57f5af83..24ff3fc0ec5 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersion.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersion.java @@ -19,7 +19,7 @@ public final class ManagedSessionReaderVersion implements com.alibaba.qwen.code. if (path.startsWith("/internal/managed-session-store/") || path.startsWith("/internal/managed-tool-publications/")) { String header = request.getHeader(HEADER); - if (header != null && !header.matches("[1234]")) { + if (header != null && !header.matches("[12345]")) { throw new ManagedAgentException(400, "invalid_managed_session_reader_version", "The Managed Session reader version is invalid."); } diff --git a/packages/managed-agent-server/server/src/main/resources/application.yml b/packages/managed-agent-server/server/src/main/resources/application.yml index 1b9b3cf3038..c52e403492b 100644 --- a/packages/managed-agent-server/server/src/main/resources/application.yml +++ b/packages/managed-agent-server/server/src/main/resources/application.yml @@ -135,6 +135,7 @@ qwen: token: '${QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN:}' provisioner: '${QWEN_MANAGED_AGENT_RUNTIME_PROVISIONER:local-process}' v3-result-window: '${QWEN_MANAGED_AGENT_RUNTIME_BROKER_V3_RESULT_WINDOW:30m}' + tool-measurements: '${QWEN_MANAGED_AGENT_RUNTIME_TOOL_MEASUREMENTS:false}' workspace-id: '${QWEN_MANAGED_AGENT_WORKSPACE_ID:}' workspace-creation-root: '${QWEN_MANAGED_AGENT_WORKSPACE_CREATION_ROOT:}' local-directory-import-enabled: '${QWEN_MANAGED_AGENT_LOCAL_DIRECTORY_IMPORT_ENABLED:false}' diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActivityDiagnosticsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActivityDiagnosticsTest.java index 41f2dfb5505..4f7986763d6 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActivityDiagnosticsTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActivityDiagnosticsTest.java @@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedActivityDiagnostics; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitResource; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements; import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; @@ -99,6 +100,56 @@ class ManagedActivityDiagnosticsTest { assertThat(restarted.findActivityStatistics(TENANT, SESSION)).isEqualTo(summary); } + @Test + void measuredExecutionCommitsWithItsProjectionAndReplaysWithoutDoubleCounting() throws Exception { + commitRequests(); + String execution = "execution-measured"; + ObjectNode observation = fact(1, "edit").put("executionCallId", execution).put("admission", "allowed"); + observation.putObject("file").put("operation", "edit").put("path", "src/file.ts").put("cwdRelative", "."); + ObjectNode source = evidence("intent:" + execution); + source.withArray("observations").add(observation); + CommitResource diagnostics = resource(ManagedActivityDiagnostics.KIND, ExtensionRecordJournal.bytes(source)); + CommitResource args = resource("managed-tool-args", "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var intent = journal.request("recordIntent", execution, args.digest().getBytes(java.nio.charset.StandardCharsets.UTF_8), 1001, event -> { + event.put("kind", "tool.intent").put("eventId", "intent:" + execution); + ObjectNode payload = event.putObject("payload").put("executionCallId", execution).put("batchId", "assistant").put("ordinal", 1) + .put("outcomeSource", "runtime"); + payload.set("toolDefinitionRef", ManagedExtensionRecordStoreTest.hookRef(args)); + payload.set("argsRef", ManagedExtensionRecordStoreTest.hookRef(args)); + payload.set("diagnosticsRef", ManagedExtensionRecordStoreTest.hookRef(diagnostics)); + }, lines -> lines); + commit(withResources(intent, ManagedActivityDiagnostics.commitDigest(args.digest(), ManagedExtensionRecordStoreTest.hookRef(diagnostics)), List.of(args, diagnostics))); + JsonNode fixture = json.readTree(Files.readString(Path.of("..", "..", "core", "src", "managed-runtime", "contracts", "managed-tool-measurements-v1.fixtures.json"))); + ObjectNode body = fixture.path("evidence").deepCopy(); + body.put("executionCallId", execution).set("invocationId", observation.get("invocationId")); + CommitResource measured = resource(ManagedToolMeasurements.KIND, ExtensionRecordJournal.bytes(body)); + CommitResource outcome = resource("managed-tool-outcome", ExtensionRecordJournal.bytes(json.createObjectNode().put("executionCallId", execution))); + ObjectNode carrier = json.createObjectNode().put("executionCallId", execution); + carrier.set("toolOutcomeRef", ManagedExtensionRecordStoreTest.hookRef(outcome)); + carrier.set("measurementsRef", ManagedExtensionRecordStoreTest.hookRef(measured)); + var base = journal.request("recordToolMeasurements", execution, ExtensionRecordJournal.bytes(body), 1002, event -> { + event.put("kind", "tool.measured").put("eventId", "tool-measured:" + execution).set("payload", carrier); + }, lines -> lines); + var request = withResources(base, ManagedToolMeasurements.commitDigest(carrier), List.of(outcome, measured)); + commit(request); + JsonNode summary = records.findActivityStatistics(TENANT, SESSION).deepCopy(); + transactions.executeWithoutResult(status -> journal.commit(request)); + assertThat(records.findActivityStatistics(TENANT, SESSION)).isEqualTo(summary); + assertThat(summary.path("tools").path("requested").asInt()).isEqualTo(4); + assertThat(summary.path("files").path("measurements").path("executionDurationSamples").asText()).isEqualTo("1"); + assertThat(summary.path("files").path("measurements").path("modelLinesAdded").asText()).isEqualTo("3"); + assertThat(summary.path("files").path("measurements").path("appliedLinesAdded").asText()).isEqualTo("2"); + assertThat(jdbc.queryForObject("SELECT storage_version FROM ma_session_head WHERE session_id = ?", Integer.class, SESSION)).isEqualTo(5); + var api = new ManagedSessionActivityService(mock(ManagedAgentService.class), records); + assertThat(api.list(TENANT, "reader", SESSION, "tools", null, 100).toString()).doesNotContain("_measurementEvidenceDigest", "_measurementOutcomeKnown", "resultDigest"); + transactions.executeWithoutResult(status -> { + jdbc.update("DELETE FROM ma_resource_ref WHERE owner_kind = 'SESSION_RECORD' AND session_id = ?", SESSION); + jdbc.update("DELETE FROM ma_session_record WHERE session_id = ?", SESSION); + }); + journal.acquire(); + assertThat(records.findActivityStatistics(TENANT, SESSION)).isEqualTo(summary); + } + @Test void conflictingSourceRollsBackJournalDetailsAndSummary() { commitRequests(); @@ -335,6 +386,13 @@ class ManagedActivityDiagnosticsTest { ExtensionRecordJournal.sha256(body), Base64.getEncoder().encodeToString(body)); } + private CommitTransactionRequest withResources(CommitTransactionRequest base, String digest, List resources) { + return new CommitTransactionRequest(base.workspaceId(), base.writerId(), base.writerGeneration(), base.expectedJournalRevision(), + base.expectedCommittedSequence(), base.transactionId(), base.operation(), base.commandId(), digest, + base.firstSequence(), base.lastSequence(), base.eventCount(), base.eventsDigest(), base.previousCommitDigest(), base.commitDigest(), + base.activationEpoch(), base.latestCheckpointResourceId(), base.recordCount(), base.recordBytesBase64(), base.recordDigest(), resources); + } + private void commit(CommitTransactionRequest request) { transactions.executeWithoutResult(status -> journal.commit(request)); journal.committed(request); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java index 64b7d0be9f7..782309480e0 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java @@ -92,6 +92,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -1736,6 +1738,21 @@ class ManagedAgentApiContractTest { assertKnownGaps(drift, "request", "response"); } + @Test + void activityMeasurementsExtendEveryClosedDetailSchemaWithoutExposingPrivateEvidence() throws Exception { + JsonNode fixture = json(Files.readString(Path.of("..", "..", "core", "src", "managed-runtime", "contracts", "managed-tool-measurements-v1.fixtures.json"))); + ObjectNode call = objectMapper.createObjectNode().put("id", "call").put("name", "edit"); + ObjectNode fact = com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedActivityDiagnostics.request("tenant", "session", "message", 0, call, 1, 1000); + fact.set("measurements", com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements.publicMeasurements(fixture.path("evidence"), 2)); + assertThat(CONTRACT.validate("/components/schemas/ManagedActivityInvocation", fact)).isEmpty(); + fact.put("_measurementEvidenceDigest", "a".repeat(64)); + assertThat(CONTRACT.validate("/components/schemas/ManagedActivityInvocation", fact)).isNotEmpty(); + for (String schema : List.of("ManagedActivityFilesPage", "ManagedActivitySkillsPage")) { + assertThat(CONTRACT.node("/components/schemas/" + schema + "/properties/items/items/properties/measurements/$ref").asText()) + .isEqualTo("#/components/schemas/ManagedActivityMeasurement"); + } + } + private void exchangeDiagnostics(Map drift, String tenant, String otherTenant, java.security.Principal principal, String session) throws Exception { String[][] routes = { diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolMeasurementsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolMeasurementsTest.java new file mode 100644 index 00000000000..c7058e02e3a --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedToolMeasurementsTest.java @@ -0,0 +1,113 @@ +package com.alibaba.qwen.code.managedagent; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedActivityDiagnostics; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedToolMeasurements; +import com.alibaba.qwen.code.runtimebroker.CsiNativeActivationProof; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ManagedToolMeasurementsTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void sharedPacketAndUtf8DigestContract() throws Exception { + JsonNode fixture = json.readTree(Files.readString(Path.of("..", "..", "core", "src", "managed-runtime", "contracts", "managed-tool-measurements-v1.fixtures.json"))); + for (JsonNode sample : fixture.path("cases")) { + if (sample.path("valid").asBoolean()) { + ManagedToolMeasurements.requireMeasurements(sample.path("measurements")); + ObjectNode evidence = fixture.path("evidence").deepCopy(); + evidence.set("measurements", sample.path("measurements")); + JsonNode duration = ManagedToolMeasurements.publicMeasurements(evidence, 2).path("executionDurationMs"); + assertThat(duration.isNull() || duration.asText().matches("[0-9]+")).isTrue(); + } else { + assertThatThrownBy(() -> ManagedToolMeasurements.requireMeasurements(sample.path("measurements"))) + .as(sample.path("id").asText()).isInstanceOf(RuntimeException.class); + } + } + ManagedToolMeasurements.requireEvidence(fixture.path("evidence")); + assertThat(DigestUtils.sha256Hex(CsiNativeActivationProof.canonical(fixture.path("wire")))) + .isEqualTo(fixture.path("resultDigest").asText()); + ObjectNode payload = json.createObjectNode().put("executionCallId", "execution"); + payload.putObject("toolOutcomeRef").put("digest", "a".repeat(64)); + payload.putObject("measurementsRef").put("digest", "b".repeat(64)); + ObjectNode event = json.createObjectNode().put("kind", "tool.measured"); + event.set("payload", payload); + String digest = ManagedToolMeasurements.commitDigest(payload); + assertThat(ManagedActivityDiagnostics.matchesCommit(List.of(event), "recordToolMeasurements", "execution", digest)).isTrue(); + assertThat(ManagedActivityDiagnostics.matchesCommit(List.of(event), "recordToolMeasurements", "other", digest)).isFalse(); + assertThat(ManagedActivityDiagnostics.matchesCommit(List.of(event), "recordToolMeasurements", "execution", "c".repeat(64))).isFalse(); + assertThat(ManagedActivityDiagnostics.matchesCommit(List.of(event), "recordToolReceipt", "execution", digest)).isFalse(); + assertThat(ManagedActivityDiagnostics.matchesCommit(List.of(event, event), "recordToolMeasurements", "execution", digest)).isFalse(); + } + + @Test + void transformedOrUnprovenInputsDoNotClaimModelProposalLines() throws Exception { + JsonNode fixture = json.readTree(Files.readString(Path.of("..", "..", "core", "src", "managed-runtime", "contracts", "managed-tool-measurements-v1.fixtures.json"))); + for (String proposal : List.of("modified", "unknown")) { + ObjectNode evidence = fixture.path("evidence").deepCopy(); + evidence.put("modelProposal", proposal); + ManagedToolMeasurements.requireEvidence(evidence); + ObjectNode measured = ManagedToolMeasurements.publicMeasurements(evidence, 2); + assertThat(measured.path("availability").asText()).isEqualTo("partial"); + assertThat(measured.path("file").path("modelLinesAdded").isNull()).isTrue(); + assertThat(measured.path("file").path("modelLinesRemoved").isNull()).isTrue(); + assertThat(measured.path("file").path("appliedLinesAdded").asText()).isEqualTo("2"); + ObjectNode metrics = json.createObjectNode().put("requested", "1"); + ObjectNode fact = json.createObjectNode(); + fact.set("measurements", measured); + ManagedToolMeasurements.contribute(metrics, fact, 1); + ManagedToolMeasurements.availability(metrics); + assertThat(metrics.path("measurements").path("modelDiffSamples").asText()).isEqualTo("0"); + assertThat(metrics.path("measurements").path("appliedDiffSamples").asText()).isEqualTo("1"); + assertThat(metrics.path("measurements").path("availability").asText()).isEqualTo("partial"); + } + } + + @Test + void lazyContributionKeepsLegacyBytesAndUsesSamplesRatherThanRequests() throws Exception { + JsonNode fixture = json.readTree(Files.readString(Path.of("..", "..", "core", "src", "managed-runtime", "contracts", "managed-tool-measurements-v1.fixtures.json"))); + ObjectNode epoch = json.createObjectNode().put("epochId", "e").put("startEventId", "message:a"); + ObjectNode summary = ManagedActivityDiagnostics.summary(epoch, 1, 1000); + ObjectNode call = json.createObjectNode().put("id", "call").put("name", "edit"); + ObjectNode before = ManagedActivityDiagnostics.request("tenant", "session", "a", 0, call, 1, 1000); + before.put("toolKind", "builtin").put("execution", "error").put("delivery", "unavailable"); + before.putObject("file").put("operation", "edit").putNull("path").putNull("cwdRelative"); + ManagedActivityDiagnostics.contribute(summary, null, before); + String legacy = summary.toString(); + ManagedActivityDiagnostics.contribute(summary, before, before.deepCopy()); + assertThat(summary.toString()).isEqualTo(legacy).doesNotContain("measurements"); + ObjectNode after = before.deepCopy().put("execution", "success").put("_measurementEvidenceDigest", "a".repeat(64)).put("_measurementOutcomeKnown", true); + after.set("measurements", ManagedToolMeasurements.publicMeasurements(fixture.path("evidence"), 2)); + ManagedActivityDiagnostics.contribute(summary, before, after); + JsonNode measured = summary.path("tools").path("measurements").deepCopy(); + assertThat(measured.path("executionDurationSamples").asText()).isEqualTo("1"); + assertThat(measured.path("executionDurationMs").asText()).isEqualTo("0"); + assertThat(measured.path("modelLinesAdded").asText()).isEqualTo("3"); + assertThat(measured.path("appliedLinesAdded").asText()).isEqualTo("2"); + ManagedActivityDiagnostics.contribute(summary, after, after.deepCopy()); + assertThat(summary.path("tools").path("measurements")).isEqualTo(measured); + ObjectNode weaker = before.deepCopy().put("delivery", "complete"); + ObjectNode merged = ManagedActivityDiagnostics.merge(after, weaker, false); + assertThat(merged.path("execution").asText()).isEqualTo("success"); + assertThat(merged.path("delivery").asText()).isEqualTo("complete"); + assertThat(merged.has("conflictingEvidence")).isFalse(); + ManagedActivityDiagnostics.contribute(summary, after, merged); + ManagedActivityDiagnostics.contribute(summary, null, before.deepCopy().put("invocationId", "b".repeat(64))); + assertThat(summary.path("tools").path("requested").asInt()).isEqualTo(2); + assertThat(summary.path("tools").path("measurements").path("executionDurationSamples").asText()).isEqualTo("1"); + assertThat(summary.path("tools").path("measurements").path("availability").asText()).isEqualTo("partial"); + assertThat(summary.path("toolBuckets").get(0).path("metrics").has("measurements")).isFalse(); + ObjectNode unknown = after.deepCopy().put("execution", "error").put("_measurementOutcomeKnown", false); + assertThat(ManagedActivityDiagnostics.merge(unknown, before.deepCopy().put("execution", "success"), true).path("execution").asText()).isEqualTo("success"); + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersionTest.java index 1504b773732..cd281a8f88a 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersionTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedSessionReaderVersionTest.java @@ -49,8 +49,13 @@ class ManagedSessionReaderVersionTest { request.removeHeader(ManagedSessionReaderVersion.HEADER); request.addHeader(ManagedSessionReaderVersion.HEADER, "4"); assertDoesNotThrow(() -> new ManagedSessionReaderVersion().requireReadable(4)); + assertEquals(409, assertThrows(ManagedAgentException.class, + () -> new ManagedSessionReaderVersion().requireReadable(5)).getStatus()); request.removeHeader(ManagedSessionReaderVersion.HEADER); request.addHeader(ManagedSessionReaderVersion.HEADER, "5"); + assertDoesNotThrow(() -> new ManagedSessionReaderVersion().requireReadable(5)); + request.removeHeader(ManagedSessionReaderVersion.HEADER); + request.addHeader(ManagedSessionReaderVersion.HEADER, "6"); assertEquals(400, assertThrows(ManagedAgentException.class, () -> new ManagedSessionReaderVersion().requireReadable(4)).getStatus()); } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceMeasurementScopeTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceMeasurementScopeTest.java new file mode 100644 index 00000000000..b8f6dd34222 --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceMeasurementScopeTest.java @@ -0,0 +1,46 @@ +package com.alibaba.qwen.code.managedagent.workspace.runtime; + +import com.alibaba.qwen.code.runtimebroker.CsiFilesRetirementProfile; +import com.alibaba.qwen.code.runtimebroker.ManagedExtensionExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.ResidentWorkspaceProfile; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class WorkspaceMeasurementScopeTest { + private static RuntimeScope scope(String tenant, String workspace, String generation, + String cwd, String profile, String isolation) { + return new RuntimeScope(tenant, workspace, generation, cwd, profile, isolation); + } + + @Test + void recordedOrdinaryProfileSurvivesEnableAndDisableButOtherScopeChangesStayClosed() { + String old = WorkspaceExecutionProfile.CAPABILITY_DIGEST; + String measured = WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST; + for (String isolation : List.of("session", "workspace")) { + var original = scope("tenant", "workspace", "1", "/workspace", old, isolation); + var next = scope("tenant", "workspace", "1", "/workspace", measured, isolation); + assertThat(WorkspaceRuntimeResolver.matchesRecordedScope(next, original)).isTrue(); + assertThat(WorkspaceRuntimeResolver.matchesRecordedScope(original, next)).isTrue(); + for (var changed : List.of( + scope("other", "workspace", "1", "/workspace", measured, isolation), + scope("tenant", "other", "1", "/workspace", measured, isolation), + scope("tenant", "workspace", "2", "/workspace", measured, isolation), + scope("tenant", "workspace", "1", "/other", measured, isolation), + scope("tenant", "workspace", "1", "/workspace", measured, isolation.equals("session") ? "workspace" : "session"))) { + assertThat(WorkspaceRuntimeResolver.matchesRecordedScope(changed, original)).isFalse(); + } + for (String unrelated : List.of(CsiFilesRetirementProfile.CAPABILITY_DIGEST, + CsiFilesRetirementProfile.DIAGNOSTICS_DIGEST, ResidentWorkspaceProfile.CAPABILITY_DIGEST, + ManagedExtensionExecutionProfile.LINUX_CAPABILITY_DIGEST)) { + var foreign = scope("tenant", "workspace", "1", "/workspace", unrelated, isolation); + assertThat(WorkspaceRuntimeResolver.matchesRecordedScope(next, foreign)).isFalse(); + assertThat(WorkspaceRuntimeResolver.matchesRecordedScope(foreign, original)).isFalse(); + } + } + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransportTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransportTest.java index c1aece2ce74..5d05f776ff7 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransportTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/workspace/runtime/WorkspaceRuntimeTransportTest.java @@ -27,21 +27,73 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.time.Instant; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; class WorkspaceRuntimeTransportTest { + @Test + void ordinaryExecutionKeepsItsRecordedSessionAcrossMeasurementFlagChangesButRejectsChangedScope() throws Exception { + for (String recordedProfile : List.of(WorkspaceExecutionProfile.CAPABILITY_DIGEST, + WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST)) { + var fixture = sshAcquisitionFixture(recordedProfile); + var original = fixture.session().getScope(); + var binding = fixture.resolver().savedBinding(fixture.session().getHarnessSessionId()); + String desiredProfile = WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(recordedProfile) + ? WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST : WorkspaceExecutionProfile.CAPABILITY_DIGEST; + var desired = new RuntimeScope(original.getTenantId(), original.getWorkspaceId(), original.getWorkspaceGeneration(), + original.getCanonicalCwd(), desiredProfile, original.getIsolationClass()); + when(fixture.resolver().resolve(fixture.session().getHarnessSessionId(), null)) + .thenReturn(new WorkspaceRuntimeResolver.Resolved(binding, desired)); + Map reference = Map.of("callId", "original-file-call", "toolName", "read_file", + "input", Map.of("file_path", "fixture.txt")); + Map result = Map.of("executionStatus", "success", "responseParts", List.of()); + when(fixture.http().execute(fixture.lease(), fixture.session(), reference)) + .thenReturn(CompletableFuture.completedFuture(result)); + + assertThat(fixture.transport().execute(fixture.lease(), fixture.session(), reference) + .toCompletableFuture().join()).isEqualTo(result); + assertThat(fixture.session().getScope()).isSameAs(original); + assertThat(fixture.session().getScope().getCapabilityDigest()).isEqualTo(recordedProfile); + verify(fixture.http()).execute(same(fixture.lease()), same(fixture.session()), same(reference)); + verify(fixture.sessions()).findById(original, fixture.session().getRuntimeSessionId()); + verify(fixture.ownership()).assertHeld(binding, fixture.record()); + + for (var changed : List.of( + new RuntimeScope("other-tenant", original.getWorkspaceId(), original.getWorkspaceGeneration(), + original.getCanonicalCwd(), desiredProfile, original.getIsolationClass()), + new RuntimeScope(original.getTenantId(), "other-workspace", original.getWorkspaceGeneration(), + original.getCanonicalCwd(), desiredProfile, original.getIsolationClass()), + new RuntimeScope(original.getTenantId(), original.getWorkspaceId(), "other-generation", + original.getCanonicalCwd(), desiredProfile, original.getIsolationClass()), + new RuntimeScope(original.getTenantId(), original.getWorkspaceId(), original.getWorkspaceGeneration(), + original.getCanonicalCwd() + "/other", desiredProfile, original.getIsolationClass()), + new RuntimeScope(original.getTenantId(), original.getWorkspaceId(), original.getWorkspaceGeneration(), + original.getCanonicalCwd(), desiredProfile, "workspace"))) { + when(fixture.resolver().resolve(fixture.session().getHarnessSessionId(), null)) + .thenReturn(new WorkspaceRuntimeResolver.Resolved(binding, changed)); + assertThat(fixture.transport().execute(fixture.lease(), fixture.session(), reference) + .toCompletableFuture().join()).containsEntry("executionStatus", "not_started") + .containsEntry("error", Map.of("type", "workspace_unavailable", + "message", "Workspace execution was refused before dispatch.")); + } + verifyNoMoreInteractions(fixture.http()); + } + } + @Test void invalidRemoteCwdIsRejectedBeforeClaimingStorageOrInstallingContext() throws Exception { var fixture = sshAcquisitionFixture(); @@ -326,11 +378,15 @@ class WorkspaceRuntimeTransportTest { } private static Fixture sshAcquisitionFixture() throws Exception { + return sshAcquisitionFixture(WorkspaceExecutionProfile.CAPABILITY_DIGEST); + } + + private static Fixture sshAcquisitionFixture(String capabilityDigest) throws Exception { var fixture = fixture(); var original = fixture.runtime(); var oldScope = fixture.session().getScope(); var scope = new RuntimeScope(oldScope.getTenantId(), oldScope.getWorkspaceId(), oldScope.getWorkspaceGeneration(), - oldScope.getCanonicalCwd(), oldScope.getCapabilityDigest(), "session"); + oldScope.getCanonicalCwd(), capabilityDigest, "session"); var session = new RuntimeSession(fixture.session().getHarnessSessionId(), fixture.session().getRuntimeSessionId(), fixture.session().getTurnKind(), scope); var request = new RuntimeProvisionRequest(scope, session.getHarnessSessionId(), "ssh-process", original.getRequest().getStorageId()); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeToolV4TransportTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeToolV4TransportTest.java new file mode 100644 index 00000000000..19bd1340335 --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/adapter/HttpRuntimeToolV4TransportTest.java @@ -0,0 +1,372 @@ +package com.alibaba.qwen.code.runtimebroker.adapter; + +import com.alibaba.qwen.code.managedagent.VerificationFixtures; +import com.alibaba.qwen.code.runtimebroker.CsiNativeActivationProof; +import com.alibaba.qwen.code.runtimebroker.JsonCodec; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeLease; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.RuntimeSession; +import com.alibaba.qwen.code.runtimebroker.RuntimeToolMeasurements; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HttpRuntimeToolV4TransportTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private final AtomicReference> response = new AtomicReference<>(); + private final AtomicReference captured = new AtomicReference<>(); + private final AtomicReference path = new AtomicReference<>(); + private final AtomicReference authorization = new AtomicReference<>(); + private final AtomicReference leaseHeader = new AtomicReference<>(); + private final AtomicInteger requests = new AtomicInteger(); + private final HttpRuntimeTransport transport = new HttpRuntimeTransport(); + private HttpServer server; + private RuntimeLease lease; + + @BeforeEach + void startServer() throws Exception { + response.set(Map.of("protocolVersion", 4, "executionProtocol", 2, "state", "unknown")); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + requests.incrementAndGet(); + captured.set(JSON.readTree(exchange.getRequestBody())); + path.set(exchange.getRequestURI().getRawPath()); + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + leaseHeader.set( + exchange.getRequestHeaders().getFirst("X-Qwen-Managed-Lease-Id")); + byte[] bytes = JsonCodec.encode(response.get()); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + lease = + new RuntimeLease( + "measured-runtime", + URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + "measured-token", + "measured-lease", + 2); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private static RuntimeSession session(String capability) { + return new RuntimeSession( + "harness-01", + "runtime-session-01", + "bootstrap", + new RuntimeScope( + "tenant-a", + "workspace-a", + "7", + "/runtime/workspace", + capability, + "session")); + } + + private static RuntimeSession measuredSession() { + return session(WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST); + } + + private static Map reference(int executionProtocol) { + Map value = + new LinkedHashMap<>( + Map.of( + "sessionId", + "runtime-session-01", + "promptId", + "prompt-01", + "callId", + "call-01", + "argsDigest", + "a".repeat(64), + "resultProtocol", + 4, + "toolName", + executionProtocol == 3 ? "run_shell_command" : "read_file", + "input", + executionProtocol == 3 + ? Map.of("command", "printf fixture") + : Map.of("file_path", "fixture.txt"))); + if (executionProtocol == 3) { + value.put("runtimeProtocol", 3); + } + return value; + } + + private static Map measurements() { + Map value = new LinkedHashMap<>(); + value.put("protocol", "managed-tool-measurements/1"); + value.put("executionDurationMs", 7); + value.put("executionOutcome", "success"); + value.put("file", null); + return value; + } + + private static Map measuredReply( + int protocol, Map result, Map measurements) { + Map signed = new LinkedHashMap<>(); + signed.put("executionProtocol", protocol); + signed.put("result", result); + signed.put("measurements", measurements); + Map value = new LinkedHashMap<>(signed); + value.put("protocolVersion", 4); + value.put("state", "settled"); + value.put("lastSequence", 2); + value.put( + "resultDigest", + DigestUtils.sha256Hex( + CsiNativeActivationProof.canonical(JSON.valueToTree(signed)))); + return value; + } + + private static Map baseResult() { + return Map.of( + "executionStatus", + "success", + "responseParts", + List.of(Map.of("type", "text", "text", "fixture"))); + } + + private void assertWire(String operation, int protocol) { + assertEquals("/internal/managed-runtime/v4/" + operation, path.get()); + assertEquals(4, captured.get().path("protocolVersion").asInt()); + assertEquals(protocol, captured.get().path("executionProtocol").asInt()); + assertEquals(4, captured.get().path("reference").size()); + assertFalse(captured.get().path("reference").has("resultProtocol")); + assertFalse(captured.get().path("reference").has("runtimeProtocol")); + assertEquals("Bearer measured-token", authorization.get()); + assertEquals("measured-lease", leaseHeader.get()); + } + + @Test + void carriesTheSameMeasuredResultThroughExecuteStatusCancelAndDigestAcknowledgement() + throws Exception { + var wire = measuredReply(2, baseResult(), measurements()); + response.set(wire); + var session = measuredSession(); + var reference = reference(2); + var execute = + transport + .executeV4(lease, session, reference, Map.of(), null) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS); + assertWire("execute", 2); + assertEquals("read_file", captured.get().path("toolName").asText()); + assertEquals("fixture.txt", captured.get().path("input").path("file_path").asText()); + assertEquals("settled", execute.get("state")); + @SuppressWarnings("unchecked") + Map result = (Map) execute.get("result"); + assertEquals(baseResult(), RuntimeToolMeasurements.base(result)); + assertEquals( + wire.get("resultDigest"), + RuntimeToolMeasurements.metadata(result).get("resultDigest")); + assertEquals(measurements(), RuntimeToolMeasurements.metadata(result).get("measurements")); + assertEquals( + execute, + transport + .statusV4(lease, session, reference, 0) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS)); + assertWire("status", 2); + assertEquals(0, captured.get().path("afterSequence").asInt()); + assertEquals( + execute, + transport + .cancelV4(lease, session, reference) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS)); + assertWire("cancel", 2); + response.set(Map.of("protocolVersion", 4, "executionProtocol", 2, "state", "acknowledged")); + transport + .acknowledgeV4(lease, session, reference, result, null) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS); + assertWire("acknowledge", 2); + assertEquals(wire.get("resultDigest"), captured.get().path("resultDigest").asText()); + assertFalse(captured.get().has("input")); + assertFalse(captured.get().has("receipt")); + assertEquals(4, requests.get()); + } + + @Test + void preservesTheSharedCaptureEnvelopeAndForwardsItsExactReceiptWithTheOuterDigest() + throws Exception { + JsonNode suite = + JSON.readTree( + VerificationFixtures.moduleRoot() + .resolve( + "../core/src/managed-runtime/contracts/managed-tool-result-v1.fixtures.json") + .toFile()); + JsonNode envelope = suite.path("envelopeCases").get(0).path("result"); + Map base = + JsonCodec.parseObject(JSON.writeValueAsBytes(envelope), "capture fixture"); + var wire = measuredReply(3, base, measurements()); + response.set(wire); + Map capture = + Map.of( + "tenantId", + "tenant-a", + "sessionId", + "harness-01", + "turnId", + "turn-01", + "executionCallId", + "execution-01", + "bindingGeneration", + "1", + "capturePolicy", + "complete_required"); + var reference = reference(3); + var execute = + transport + .executeV4(lease, measuredSession(), reference, Map.of(), capture) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS); + assertWire("execute", 3); + assertEquals(JSON.valueToTree(capture), captured.get().path("capture")); + @SuppressWarnings("unchecked") + Map result = (Map) execute.get("result"); + assertEquals(envelope, JSON.valueToTree(RuntimeToolMeasurements.base(result))); + var manifest = + JsonCodec.parseObject( + JSON.writeValueAsBytes(envelope.path("capture").path("manifest")), + "manifest"); + var receipt = + Map.of( + "executionCallId", + "execution-01", + "manifest", + manifest, + "deliveryStatus", + "committed", + "historyRevision", + 7); + response.set(Map.of("protocolVersion", 4, "executionProtocol", 3, "state", "acknowledged")); + transport + .acknowledgeV4(lease, measuredSession(), reference, result, receipt) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS); + assertWire("acknowledge", 3); + assertEquals(wire.get("resultDigest"), captured.get().path("resultDigest").asText()); + assertEquals(JSON.valueToTree(receipt), captured.get().path("receipt")); + } + + @Test + void rejectsCorruptDigestsUnknownFieldsAndProtocolDowngradesWithoutLegacyDispatch() + throws Exception { + var wrongDigest = measuredReply(2, baseResult(), measurements()); + wrongDigest.put("resultDigest", "0".repeat(64)); + var unknownField = measuredReply(2, baseResult(), measurements()); + unknownField.put("untrustedPath", "/private/file"); + var privateMeasurement = measurements(); + privateMeasurement.put("filePath", "/private/file"); + var wrongMeasurement = measuredReply(2, baseResult(), privateMeasurement); + var downgrade = + Map.of( + "protocolVersion", 2, "state", "settled", "result", baseResult()); + for (Map invalid : + List.of(wrongDigest, unknownField, wrongMeasurement, downgrade)) { + int before = requests.get(); + response.set(invalid); + assertThrows( + ExecutionException.class, + () -> + transport + .executeV4( + lease, measuredSession(), reference(2), Map.of(), null) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS)); + assertEquals(before + 1, requests.get()); + assertEquals("/internal/managed-runtime/v4/execute", path.get()); + } + } + + @Test + void refusesLegacyProfilesUnreservedProtocolsAndNegativeCursorsBeforeSending() { + var failure = + assertThrows( + RuntimeBrokerException.class, + () -> + transport.executeV4( + lease, + session(WorkspaceExecutionProfile.CAPABILITY_DIGEST), + reference(2), + Map.of(), + null)); + assertEquals("runtime_tool_v4_unsupported", failure.getCode()); + var unreserved = reference(2); + unreserved.remove("resultProtocol"); + assertThrows( + IllegalArgumentException.class, + () -> transport.executeV4(lease, measuredSession(), unreserved, Map.of(), null)); + assertThrows( + IllegalArgumentException.class, + () -> transport.statusV4(lease, measuredSession(), reference(2), -1)); + assertEquals(0, requests.get()); + assertNull(captured.get()); + } + + @Test + void keepsMissingMeasurementsExplicitAndRejectsPayloadInAcknowledgedResponses() + throws Exception { + var wire = measuredReply(2, baseResult(), null); + response.set(wire); + var execute = + transport + .executeV4(lease, measuredSession(), reference(2), Map.of(), null) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS); + @SuppressWarnings("unchecked") + Map result = (Map) execute.get("result"); + assertTrue(RuntimeToolMeasurements.metadata(result).containsKey("measurements")); + assertNull(RuntimeToolMeasurements.metadata(result).get("measurements")); + response.set( + Map.of( + "protocolVersion", + 4, + "executionProtocol", + 2, + "state", + "acknowledged", + "result", + baseResult())); + assertThrows( + ExecutionException.class, + () -> + transport + .acknowledgeV4(lease, measuredSession(), reference(2), result, null) + .toCompletableFuture() + .get(2, TimeUnit.SECONDS)); + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisionerTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisionerTest.java index 8170e50bc74..99df1f0f2f0 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisionerTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/provisioning/external/ExternalWorkspaceProvisionerTest.java @@ -152,6 +152,35 @@ class ExternalWorkspaceProvisionerTest { assertEquals(ALIAS, request.getStorageId()); } + @Test + void measuredWorkspaceScopeKeepsItsRegisteredStorageBindingAndBootCapability() throws Exception { + ExternalWorkspaceProvisioner subject = provisioner(registration(PROVIDER, 3, MOUNT)); + RuntimeScope scope = scope(WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST); + RuntimeProvisionRequest request = subject.createRequest(scope, "isolation-measured"); + assertTrue(request.isManagedContext()); + assertEquals(ALIAS, request.getStorageId()); + assertEquals(scope, request.getScope()); + RuntimeProvisionSeed seed = seed("measured-boot"); + RuntimeResourceHandle handle = join(subject.ensureResource(request, seed, null)); + client.provisionResponse = Map.of("protocolVersion", 1, + "handle", handle.getValue().get("provider"), "endpoint", RecordingClient.ENDPOINT); + client.observeState = "RUNNING"; + RuntimeLease lease = join(subject.provision(request, seed)); + + Map boot = client.boot(); + assertEquals("managed-context/1", boot.get("managedContext")); + assertEquals(WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST, boot.get("capabilityDigest")); + assertEquals(ALIAS, boot.get("storageId")); + assertEquals(MOUNT, boot.get("mountRoot")); + assertEquals("session", boot.get("isolationClass")); + assertEquals(Map.of("kind", "external", "physicalKey", PHYSICAL, + "aliasPath", ALIAS_PATH, "mountRoot", MOUNT, + "registrationRevision", 3L), boot.get("storage")); + assertEquals(seed.getToken(), lease.getToken()); + assertTrue(subject.isUsable(lease)); + assertEquals(1, client.provisions.size()); + } + @Test void aRequestWithoutAStorageBindingIsRefused() { // A harness-Session caller that resolved no storage must not have a diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcherTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcherTest.java index 5db4964ffc4..262d5eed4b7 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcherTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionDispatcherTest.java @@ -9,6 +9,7 @@ import com.alibaba.qwen.code.runtimebroker.RuntimeProvisioner; import com.alibaba.qwen.code.runtimebroker.RuntimePublicationVerifier; import com.alibaba.qwen.code.runtimebroker.RuntimeScope; import com.alibaba.qwen.code.runtimebroker.RuntimeSession; +import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRepository; import com.alibaba.qwen.code.runtimebroker.RuntimeTransport; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; @@ -49,6 +50,39 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class RuntimeExecutionDispatcherTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void measuredAcknowledgementHonorsTheSessionOwnerOnSharedWorkspace(boolean foreignOwner) { + var fixture = new Fixture(); + var scope = new RuntimeScope("tenant", "workspace", "1", "/workspace", + WorkspaceExecutionProfile.MEASURED_CAPABILITY_DIGEST, "workspace"); + var request = new RuntimeProvisionRequest(scope, null, "local-process", "storage"); + when(fixture.binding.getRequest()).thenReturn(request); + when(fixture.binding.getLease()).thenReturn(fixture.lease); + when(fixture.binding.getBindingId()).thenReturn("binding"); + when(fixture.binding.getGeneration()).thenReturn(1L); + var session = new RuntimeSession(foreignOwner ? "other-harness" : "harness", "runtime", "bootstrap", scope); + when(fixture.sessions.findById(scope, "runtime")).thenReturn(new RuntimeSessionRecord(session, + "binding", 1, RuntimeSessionRecord.State.READY, 0, Instant.now())); + Map reference = Map.of("sessionId", "runtime", "promptId", "turn", "callId", "call", + "argsDigest", "sha256:" + "a".repeat(64), "resultProtocol", 4); + var result = Map.of("executionStatus", "success", "responseParts", java.util.List.of()); + var execution = new ToolExecutionRecord("execution", "idempotency", "binding", 1, + "harness", "runtime", "turn", "call", "sha256:" + "a".repeat(64), reference, + ToolExecutionRecord.State.SETTLED, "success", result, 1, false, null, null, 0, 0, Instant.now()); + when(fixture.transport.acknowledgeV4(fixture.lease, session, reference, result, null)) + .thenReturn(CompletableFuture.completedFuture(null)); + var acknowledged = fixture.dispatcher.acknowledgePersistedTools(fixture.binding, execution).toCompletableFuture(); + if (foreignOwner) { + var failure = assertThrows(java.util.concurrent.CompletionException.class, acknowledged::join); + assertTrue(failure.getCause() instanceof RuntimeBrokerException); + verify(fixture.transport, never()).acknowledgeV4(any(), any(), any(), any(), any()); + } else { + acknowledged.join(); + verify(fixture.transport).acknowledgeV4(fixture.lease, session, reference, result, null); + } + } + @Test @Timeout(15) void concurrentDispatchesShareReservationWhileClaimIsPending() throws Exception { @@ -165,6 +199,8 @@ class RuntimeExecutionDispatcherTest { private final ToolExecutionRepository executions = mock(ToolExecutionRepository.class); private final RuntimeProvisioner provisioner = mock(RuntimeProvisioner.class); private final RuntimeTransport transport = mock(RuntimeTransport.class); + private final RuntimeSessionRepository sessions = mock(RuntimeSessionRepository.class); + private final RuntimeBindingRecord binding = mock(RuntimeBindingRecord.class); private final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); private final ScheduledExecutorService renewalScheduler = mock(ScheduledExecutorService.class); private final ScheduledFuture renewal = mock(ScheduledFuture.class); @@ -180,8 +216,6 @@ class RuntimeExecutionDispatcherTest { private Fixture() { var registry = mock(RuntimeBindingRegistry.class); var bindings = mock(RuntimeBindingRepository.class); - var sessions = mock(RuntimeSessionRepository.class); - var binding = mock(RuntimeBindingRecord.class); var scope = new RuntimeScope("tenant", "workspace", "1", "/workspace", WorkspaceExecutionProfile.CAPABILITY_DIGEST, "session"); when(binding.getRequest()).thenReturn(new RuntimeProvisionRequest(scope, "harness", "local")); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconcilerTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconcilerTest.java index 8a29b2d8885..b586f3ceaa8 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconcilerTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/service/RuntimeExecutionReconcilerTest.java @@ -9,9 +9,10 @@ import com.alibaba.qwen.code.runtimebroker.RuntimeTransport; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord; import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; import com.alibaba.qwen.code.runtimebroker.service.RuntimeBindingRegistry.SessionContext; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.CsvSource; import java.time.Clock; import java.time.Duration; @@ -21,8 +22,11 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -38,10 +42,47 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; class RuntimeExecutionReconcilerTest { + @Test + @Timeout(5) + void droppedMeasurementRetryAfterSchedulerShutdownStillCompletesWithinTheOperationBudget() { + var scheduler = mock(ScheduledExecutorService.class); + var registry = mock(RuntimeBindingRegistry.class); + var state = mock(RuntimeExecutionState.class); + var executions = mock(ToolExecutionRepository.class); + var transport = mock(RuntimeTransport.class); + var original = mock(ToolExecutionRecord.class); + var context = mock(SessionContext.class); + var session = mock(RuntimeSession.class); + var lease = mock(RuntimeLease.class); + var closed = new AtomicBoolean(); + Clock clock = Clock.systemUTC(); + when(original.isSettled()).thenReturn(true); + when(original.getReference()).thenReturn(Map.of("resultProtocol", 4)); + when(original.getResult()).thenReturn(Map.of("executionStatus", "success")); + when(original.getExecutionCallId()).thenReturn("execution"); + when(original.getHarnessSessionId()).thenReturn("harness"); + when(original.getRuntimeSessionId()).thenReturn("runtime"); + when(registry.requireReadySession("harness", "runtime")).thenReturn(CompletableFuture.completedFuture(context)); + when(state.requireExecution(context, "execution")).thenReturn(original); + when(context.session()).thenReturn(session); + when(context.lease()).thenReturn(lease); + when(transport.statusV4(eq(lease), eq(session), any(), eq(0L))) + .thenReturn(CompletableFuture.completedFuture(Map.of("state", "executing", "lastSequence", 1L))); + var reconciler = new RuntimeExecutionReconciler(registry, state, executions, + mock(RuntimeProvisioner.class), transport, null, Duration.ofMillis(100), clock, scheduler, + closed, new ExecutionObservationCooldown(clock, scheduler)); + var waiting = reconciler.measuredResult(original, 0).toCompletableFuture(); + closed.set(true); + reconciler.cancelPendingOperations(); + scheduler.shutdownNow(); + var failure = assertThrows(ExecutionException.class, () -> waiting.get(2, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof TimeoutException); + } + @ParameterizedTest - @ValueSource(booleans = {false, true}) + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) @Timeout(15) - void anObserverDuringCooldownStampJoinsTheLookupInsteadOfCallingTheWorkerAgain(boolean failed) throws Exception { + void anObserverDuringCooldownStampJoinsTheLookupInsteadOfCallingTheWorkerAgain(boolean failed, boolean measured) throws Exception { var scheduler = Executors.newSingleThreadScheduledExecutor(); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); @@ -68,7 +109,7 @@ class RuntimeExecutionReconcilerTest { when(unknown.getState()).thenReturn(ToolExecutionRecord.State.UNKNOWN); when(unknown.getBindingId()).thenReturn("binding"); when(unknown.getRuntimeGeneration()).thenReturn(7L); - when(unknown.getReference()).thenReturn(Map.of("callId", "call")); + when(unknown.getReference()).thenReturn(measured ? Map.of("callId", "call", "resultProtocol", 4) : Map.of("callId", "call")); when(state.requireOwnedExecution("harness", "runtime", "execution")).thenReturn(unknown); when(executions.findByExecutionCallId("execution")).thenReturn(unknown); when(registry.session("runtime")).thenReturn(CompletableFuture.completedFuture(context)); @@ -82,12 +123,19 @@ class RuntimeExecutionReconcilerTest { when(provisioner.isUsable(lease)).thenReturn(true); var answer = new CompletableFuture>(); var lookups = new AtomicInteger(); - when(transport.status(eq(lease), eq(session), any(), eq(0L))).thenAnswer(call -> { - lookups.incrementAndGet(); - return answer; - }); + if (measured) { + when(transport.statusV4(eq(lease), eq(session), any(), eq(0L))).thenAnswer(call -> { + lookups.incrementAndGet(); + return answer; + }); + } else { + when(transport.status(eq(lease), eq(session), any(), eq(0L))).thenAnswer(call -> { + lookups.incrementAndGet(); + return answer; + }); + } var reconciler = new RuntimeExecutionReconciler(registry, state, executions, provisioner, transport, - null, Duration.ofSeconds(10), clock, closed, cooldown); + null, Duration.ofSeconds(10), clock, scheduler, closed, cooldown); Thread completer = null; try { var first = reconciler.observeExecution("harness", "runtime", "execution").toCompletableFuture(); @@ -95,7 +143,7 @@ class RuntimeExecutionReconcilerTest { if (failed) { answer.completeExceptionally(new IllegalStateException("worker unavailable")); } else { - answer.complete(Map.of("state", "executing")); + answer.complete(measured ? Map.of("state", "executing", "lastSequence", 1L) : Map.of("state", "executing")); } }); assertTrue(entered.await(5, TimeUnit.SECONDS), "lookup never reached cooldown stamp"); diff --git a/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties b/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties index b39d60e42c4..df7941e9446 100644 --- a/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties +++ b/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties @@ -24,7 +24,7 @@ com.alibaba.qwen.code.managedagent.persistence.AgentSessionCommands=1078 com.alibaba.qwen.code.managedagent.persistence.AgentTurnExecution=819 com.alibaba.qwen.code.managedagent.persistence.AutomationLedgerStore=820 com.alibaba.qwen.code.managedagent.persistence.ChildResultRelayStore=878 -com.alibaba.qwen.code.managedagent.persistence.JournalCommit=880 +com.alibaba.qwen.code.managedagent.persistence.JournalCommit=878 com.alibaba.qwen.code.managedagent.persistence.ManagedActionStore=830 com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore=1170 com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore=2170 @@ -37,7 +37,7 @@ com.alibaba.qwen.code.managedagent.persistence.WorkspaceRecoveryStore=802 com.alibaba.qwen.code.managedagent.persistence.WorkspaceStorageGuard=877 com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission=990 com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository=2670 -com.alibaba.qwen.code.runtimebroker.persistence.JdbcToolExecutionRepository=1235 +com.alibaba.qwen.code.runtimebroker.persistence.JdbcToolExecutionRepository=1212 # PR #16475: maintainer-approved allowance for #16535 import growth. com.alibaba.qwen.code.runtimebroker.service.RuntimeBindingProvisioning=1327 -com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerService=2945 +com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerService=2919 diff --git a/packages/web-shell/client/components/managed/ManagedActivityView.tsx b/packages/web-shell/client/components/managed/ManagedActivityView.tsx index b891a1433c1..04c9492dbc9 100644 --- a/packages/web-shell/client/components/managed/ManagedActivityView.tsx +++ b/packages/web-shell/client/components/managed/ManagedActivityView.tsx @@ -10,7 +10,72 @@ type Stats = components['schemas']['ManagedDiagnosticActivityStats']; type Page = components['schemas']['ManagedDiagnosticActivityPage']; type Metrics = components['schemas']['ManagedActivityMetrics']; -function Counts({ value }: { value: Metrics }) { +function durationMean(total: string, samples: string): string { + const count = BigInt(samples); + if (count === 0n) return '—'; + const tenths = (BigInt(total) * 10n) / count; + return `${tenths / 10n}.${tenths % 10n} ms`; +} + +function MeasurementTotals({ + value, + requested, +}: { + value?: Metrics['measurements']; + requested: number; +}) { + const t = useManagedDiagnosticsText(); + return ( +
+

{t('measurementMeaning')}

+

+ {t('measurementCoverage')} ·{' '} + {value + ? `${t(value.availability === 'partial' ? 'partialState' : value.availability)} (${value.recorded}/${requested})` + : t('not_recorded')} +

+

+ {t('executionDuration')} ·{' '} + {value && value.executionDurationSamples !== '0' + ? `${value.executionDurationMs} ms / ${value.executionDurationSamples} ${t('samples')}` + : t('unmeasured')} +

+ {value && value.executionDurationSamples !== '0' && ( +

+ {t('meanDuration')} ·{' '} + {durationMean( + value.executionDurationMs, + value.executionDurationSamples, + )} +

+ )} + {value && value.fileSamples !== '0' && ( + <> +

+ {t('modelLines')} ·{' '} + {value.modelDiffSamples !== '0' + ? `+${value.modelLinesAdded} / −${value.modelLinesRemoved} (${value.modelDiffSamples} ${t('samples')})` + : t('unmeasured')} +

+

+ {t('appliedLines')} ·{' '} + {value.appliedDiffSamples !== '0' + ? `+${value.appliedLinesAdded} / −${value.appliedLinesRemoved} (${value.appliedDiffSamples} ${t('samples')})` + : t('unmeasured')} +

+ + )} +
+ ); +} + +function Counts({ + value, + showMeasurements = false, +}: { + value: Metrics; + showMeasurements?: boolean; +}) { const t = useManagedDiagnosticsText(); return (
@@ -43,6 +108,12 @@ function Counts({ value }: { value: Metrics }) {
{value.execution[key].toLocaleString()}
))} + {showMeasurements && ( + + )}
); } @@ -200,7 +271,14 @@ export function ManagedActivityView({ {reasonText(reason)}

))} - {metrics && kind !== 'skills' && } + {metrics && kind !== 'skills' && ( + + )} + {kind !== 'skills' && statistics?.measurementStartSequence && ( +

+ {t('measurementStart')} · {statistics.measurementStartSequence} +

+ )} {kind === 'files' && statistics && (
{(['read', 'write', 'edit'] as const).map((operation) => ( @@ -209,7 +287,10 @@ export function ManagedActivityView({ {t(operation)} ·{' '} {statistics.fileOperations[operation].requested} - + ))}
@@ -297,6 +378,45 @@ export function ManagedActivityView({
{t(item.capture === 'partial' ? 'partialState' : item.capture)}
+ {kind !== 'skills' && ( + <> +
{t('measurementCoverage')}
+
+ {item.measurements + ? t( + item.measurements.availability === 'partial' + ? 'partialState' + : item.measurements.availability, + ) + : t('not_recorded')} +
+
{t('executionDuration')}
+
+ {item.measurements?.executionDurationMs != null + ? `${item.measurements.executionDurationMs} ms` + : t('unmeasured')} +
+ {item.measurements?.file && + item.measurements.file.operation !== 'read' && ( + <> +
{t('fileChanges')}
+
{t(item.measurements.file.status)}
+
{t('modelLines')}
+
+ {item.measurements.file.modelLinesAdded != null + ? `+${item.measurements.file.modelLinesAdded} / −${item.measurements.file.modelLinesRemoved}` + : t('unmeasured')} +
+
{t('appliedLines')}
+
+ {item.measurements.file.appliedLinesAdded != null + ? `+${item.measurements.file.appliedLinesAdded} / −${item.measurements.file.appliedLinesRemoved}` + : t('unmeasured')} +
+ + )} + + )} {item.skill && ( <>
{t(item.skill.resourceKind)}
diff --git a/packages/web-shell/client/components/managed/ManagedDiagnosticsPanel.test.tsx b/packages/web-shell/client/components/managed/ManagedDiagnosticsPanel.test.tsx index f229708d1e5..feb63c02540 100644 --- a/packages/web-shell/client/components/managed/ManagedDiagnosticsPanel.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedDiagnosticsPanel.test.tsx @@ -261,6 +261,60 @@ describe('ManagedDiagnosticsPanel', () => { expect(document.body.textContent).toContain('assistant-1'); }); + it('shows measured zero, independent model/applied lines and a sample-based duration mean', async () => { + const statistics = structuredClone(activityStatistics); + statistics.statistics!.tools.requested = 4; + statistics.statistics!.tools.measurements = { + availability: 'partial', + recorded: '2', + unsupported: '0', + partial: '0', + executionDurationMs: '6', + executionDurationSamples: '2', + fileSamples: '2', + modelDiffSamples: '1', + appliedDiffSamples: '1', + modelLinesAdded: '3', + modelLinesRemoved: '1', + appliedLinesAdded: '0', + appliedLinesRemoved: '0', + }; + statistics.statistics!.measurementStartSequence = '9'; + vi.mocked(provider.getActivityStatistics!).mockResolvedValue(statistics); + const page = structuredClone(activityPage); + page.items[0].delivery = 'unavailable'; + page.items[0].measurements = { + availability: 'available', + sourceSequence: '9', + executionDurationMs: '0', + file: { + operation: 'edit', + status: 'unchanged', + modelLinesAdded: '3', + modelLinesRemoved: '1', + appliedLinesAdded: '0', + appliedLinesRemoved: '0', + }, + }; + vi.mocked(provider.listActivity!).mockResolvedValue(page); + await render(); + await selectTab('Statistics'); + await act(async () => + [...document.querySelectorAll('button')] + .find((button) => button.textContent === 'Tools')! + .click(), + ); + const text = document.body.textContent!; + expect(text).toContain('3.0 ms'); + expect(text).not.toContain('1.5 ms'); + expect(text).toContain('0 ms'); + expect(text).toContain('+3 / −1'); + expect(text).toContain('+0 / −0'); + expect(text).toContain('Unchanged'); + expect(text).toContain('First measurement sequence'); + expect(text).toContain('DeliveryUnavailable'); + }); + it.each(['statistics-session', 'page-session', 'page-kind'])( 'rejects mismatched activity identity: %s', async (mismatch) => { diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 704f5ecae10..10c1da1e677 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -3076,6 +3076,7 @@ export interface components { unavailable: number; unknown: number; }; + measurements?: components["schemas"]["ManagedActivityMeasurementTotals"]; }; ManagedActivityFile: { /** @enum {string} */ @@ -3120,6 +3121,7 @@ export interface components { conflictingEvidence?: boolean; taskState?: string; taskRuntimeState?: string | null; + measurements?: components["schemas"]["ManagedActivityMeasurement"]; }; ManagedActivityStatistics: { tools: components["schemas"]["ManagedActivityMetrics"]; @@ -3156,6 +3158,7 @@ export interface components { write: components["schemas"]["ManagedActivityMetrics"]; edit: components["schemas"]["ManagedActivityMetrics"]; }; + measurementStartSequence?: string; }; ManagedDiagnosticActivityStats: { /** @constant */ @@ -3233,6 +3236,7 @@ export interface components { conflictingEvidence?: boolean; taskState?: string; taskRuntimeState?: string | null; + measurements?: components["schemas"]["ManagedActivityMeasurement"]; }[]; hasMore: boolean; nextCursor: string | null; @@ -3279,6 +3283,7 @@ export interface components { conflictingEvidence?: boolean; taskState?: string; taskRuntimeState?: string | null; + measurements?: components["schemas"]["ManagedActivityMeasurement"]; }[]; hasMore: boolean; nextCursor: string | null; @@ -3293,6 +3298,38 @@ export interface components { limit?: number; }; ManagedDiagnosticActivityPage: components["schemas"]["ManagedActivityToolsPage"] | components["schemas"]["ManagedActivityFilesPage"] | components["schemas"]["ManagedActivitySkillsPage"]; + ManagedActivityMeasurement: { + sourceSequence: string; + /** @enum {string} */ + availability: "available" | "partial" | "unsupported"; + executionDurationMs: string | null; + file: { + /** @enum {string} */ + operation: "read" | "write" | "edit"; + /** @enum {string} */ + status: "measured" | "unchanged" | "unmeasured"; + modelLinesAdded: string | null; + modelLinesRemoved: string | null; + appliedLinesAdded: string | null; + appliedLinesRemoved: string | null; + } | null; + }; + ManagedActivityMeasurementTotals: { + /** @enum {string} */ + availability: "available" | "partial" | "not_recorded" | "unsupported"; + recorded: string; + unsupported: string; + executionDurationMs: string; + executionDurationSamples: string; + fileSamples: string; + modelDiffSamples: string; + appliedDiffSamples: string; + modelLinesAdded: string; + modelLinesRemoved: string; + appliedLinesAdded: string; + appliedLinesRemoved: string; + partial: string; + }; }; responses: { /** @description The authenticated actor belongs to another tenant than X-Qwen-Tenant-Id or has an invalid ID (actor_scope_mismatch, which the tenant filter answers on every /v1/agents/ and WebShell route), or the resource is readable but the actor lacks this operation or the original Action responder permission. Artifact content reads may also return artifact_content_forbidden when the actor cannot read the original bytes. */ diff --git a/packages/web-shell/client/components/managed/managed-diagnostics-messages.ts b/packages/web-shell/client/components/managed/managed-diagnostics-messages.ts index f38d04e7ddf..43e29c7027c 100644 --- a/packages/web-shell/client/components/managed/managed-diagnostics-messages.ts +++ b/packages/web-shell/client/components/managed/managed-diagnostics-messages.ts @@ -16,6 +16,25 @@ const messages = { skillLoads: ['Skill loading', 'Skill 加载'], collectionStart: ['Observed since', '自此时刻起统计'], requested: ['Requests', '请求次数'], + available: ['Available', '已采集'], + measurementMeaning: [ + 'Executor duration excludes approval, queues and delivery. Line counts accumulate native write/edit operations, not the current Git diff. Older and unsupported paths have no measured samples.', + '执行耗时不含审批、排队和交付。行数按原生写入/编辑操作累计,不代表当前 Git diff。旧数据与不支持的路径不计入测量样本。', + ], + measurementCoverage: ['Measurement coverage', '测量覆盖'], + executionDuration: ['Executor duration', '执行耗时'], + meanDuration: ['Mean measured duration', '有效样本平均耗时'], + samples: ['samples', '样本'], + modelLines: [ + 'Model proposed lines (write/edit)', + '模型提议行数(写入/编辑)', + ], + appliedLines: ['Applied lines (write/edit)', '实际应用行数(写入/编辑)'], + unmeasured: ['Unmeasured', '未测量'], + measured: ['Measured', '已测量'], + unchanged: ['Unchanged', '无变化'], + fileChanges: ['Applied changes', '应用变更'], + measurementStart: ['First measurement sequence', '首个测量来源 sequence'], allowed: ['Allowed', '已准入'], denied: ['Denied', '已拒绝'], invalid: ['Invalid arguments', '参数无效'], diff --git a/packages/web-shell/client/e2e/managed-diagnostics.spec.ts b/packages/web-shell/client/e2e/managed-diagnostics.spec.ts index 038699e8418..56e8aecff07 100644 --- a/packages/web-shell/client/e2e/managed-diagnostics.spec.ts +++ b/packages/web-shell/client/e2e/managed-diagnostics.spec.ts @@ -169,6 +169,15 @@ for (const width of [1280, 390]) { page, }, testInfo) => { type Schemas = components['schemas']; + const measuredItem: Schemas['ManagedActivityInvocation'] = { + ...activityItem, + measurements: { + availability: 'available', + sourceSequence: '9', + executionDurationMs: '0', + file: null, + }, + }; const errors: string[] = []; const unexpected: string[] = []; const activityRequests: Array<{ kind: string; cursor?: string }> = []; @@ -219,6 +228,25 @@ for (const width of [1280, 390]) { ...activityStatistics, statistics: { ...activityStatistics.statistics!, + measurementStartSequence: '9', + tools: { + ...activityStatistics.statistics!.tools, + measurements: { + availability: 'available', + recorded: '1', + unsupported: '0', + partial: '0', + executionDurationMs: '0', + executionDurationSamples: '1', + fileSamples: '0', + modelDiffSamples: '0', + appliedDiffSamples: '0', + modelLinesAdded: '0', + modelLinesRemoved: '0', + appliedLinesAdded: '0', + appliedLinesRemoved: '0', + }, + }, skills: activityStatistics.statistics!.tools, skillLoads: activityStatistics.statistics!.tools, skillBuckets: [ @@ -304,7 +332,7 @@ for (const width of [1280, 390]) { ? [skillItem] : body.cursor ? [deniedItem] - : [activityItem]; + : [measuredItem]; return route.fulfill({ json: { ...header, @@ -366,6 +394,9 @@ for (const width of [1280, 390]) { await dialog.getByRole('tab', { name: 'Statistics', exact: true }).click(); await dialog.getByRole('button', { name: 'Tools', exact: true }).click(); await expect(dialog).toContainText('Observed since'); + await expect(dialog).toContainText('Mean measured duration'); + await expect(dialog).toContainText('0.0 ms'); + await expect(dialog).toContainText('First measurement sequence'); await expect(dialog).toContainText( 'Some observations are unavailable or incomplete.', ); @@ -382,6 +413,9 @@ for (const width of [1280, 390]) { await expect( readCall.locator('dt:has-text("Output capture") + dd'), ).toHaveText('Complete'); + await expect( + readCall.locator('dt:has-text("Executor duration") + dd'), + ).toHaveText('0 ms'); await page.screenshot({ path: testInfo.outputPath('activity-tools.png'), animations: 'disabled',