mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-22 06:49:52 +00:00
feat(managed-agent): H5a channel route and delivery record contract (#13548)
* feat(managed-agent): H5a channel route and delivery record contract
Land slice H5a of the Managed Agent extension runtime (#12827, stage H of
#12380): the durable record contract for Channels. Define the
managed-channel_route and managed-channel_delivery record bodies — the two
domains H0b registered but left unbodied — with one shared fixture corpus
(83 shape cases, 54 successor cases) replayed identically by TypeScript and
Java, register both bodies with taskKind null, teach the Session authority
their resource closures, and keep both domains refused for submission. The
H5 design document (both languages) moves from contract direction to the
pinned byte-level contract this slice freezes.
Also widen ManagedExtensionRecords' value-based comparator to the package
so the channel records share the sibling JSON.stringify-compatible numeric
semantics instead of a double comparison that splits -0.0 from 0.0.
Refs QwenLM/qwen-code#12827 QwenLM/qwen-code#12380
* fix(managed-agent): close the two H5a review findings from #13548
Two follow-ups from the wenshao review on the H5a record contract:
- The segment plan parser iterated with Array.prototype.map, so a sparse
plan emptied its holes past every check and then failed its own durable
round-trip once the holes serialized as null. Walk every index so a hole
refuses as a non-object entry, pin a TypeScript regression for the
sparse shapes, and add the explicit null-element corpus case both
languages replay (84 shape cases, 54 successors).
- The Java store's resource closure only covered the MCP, Hook,
child-run and monitor bodies, so a channel journal could persist a
record the Session authority cannot reopen. Enumerate the channel
route's policyRef and the delivery's contentRef, every segment
contentRef and every non-null receipt proofRef in the commit
transaction, with positive commits (planned, sending with a proofed
receipt) and one refusal per missing resource kind.
Refs QwenLM/qwen-code#12827 QwenLM/qwen-code#13548
* fix(managed-agent): close the review F1/F2 follow-ups on H5a
- Leaving `unknown` requires proof: `unknown → partial` must settle a
segment the unknown revision did not, in both validators, or the next
`sending` revision could re-send a segment the provider may already
hold (decision 5; the previous corpus composed the two-step bypass
byte-for-byte). Corpus re-derived: `delivery-unknown-proves-partial`
gains the third segment with a real new receipt, and
`delivery-unknown-partial-without-proof` pins the refusal.
- Corpus strength: pin the ten rules both languages enforced without a
fixture (thread scope with a senderId, single with a chatId, unknown
scope kind with all-null carriers, non-boolean cancelRequested,
unknown/rejected with every segment receipted, route accountId drift,
delivery routeId drift, malformed proofRef, malformed segment
contentRef), and correct `delivery-cancelled-with-receipt` so the
refusal rides the zero-receipt conjunct it names.
- Design docs (both languages) record the leaving-unknown rule and the
updated 92/57 corpus size.
Refs QwenLM/qwen-code#12827 QwenLM/qwen-code#13548
* fix(managed-agent): repair the store-test splice after the H4a merge
The H4a/H5a registry merge put the closure test and helper methods into a
splice that lost channelDelivery's closing brace and interleaved the H4a
tests mid-method. Re-laid the file on top of origin/main with the closure
test and helpers inserted before childRun, restoring compilation and the
full 25-test store suite.
Refs QwenLM/qwen-code#12827 QwenLM/qwen-code#13548
* fix(managed-agent): restore @Test on the channel resource-closure regression
The store-test splice after the H4a merge dropped the annotation while
re-laying the file, so normal test runs silently skipped the regression
(the review-5438970962 Suggestion). Restored it; the suite runs 26 tests
including all positive commits and the four missing-resource refusals.
Refs QwenLM/qwen-code#13548
* chore(docs): format the daemon event-schema queue table for the Prettier gate
main's #13281 added the pending-prompt queue table unformatted, so once
the branch merged main the repo-wide `node scripts/lint.js --prettier`
gate turned the Lint & Static lane red on `09-event-schema.md` — the
same fix H4b already carries as `e72c63a11c` upstream of its line.
Pure formatting; content unchanged.
Refs QwenLM/qwen-code#13548
* fix(managed-agent): widen RECORD_BODIES past Map.of's ten-pair cap
H6a's schedule and automation_run bodies bring the registry to eleven
bodies once this branch merges with main; Map.of constructor overloads
stop at ten. Convert to Map.ofEntries, mirroring the entries layout
that both contract surfaces and the projection fixtures drive.
Refs QwenLM/qwen-code#12827 QwenLM/qwen-code#13548
This commit is contained in:
parent
e31d5500f1
commit
e92b60a282
15 changed files with 9198 additions and 65 deletions
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
[English](2026-10-04-managed-channels.md) | [简体中文](2026-10-04-managed-channels.zh-CN.md)
|
||||
|
||||
Status: proposed design; nothing in this document is implemented, and no domain
|
||||
it names is enabled for submission. This is the design for slice H5 of
|
||||
Status: slice H5a (the record contract of both domains) is implemented; slices
|
||||
H5b and H5c remain proposed, and no domain this document names is enabled for
|
||||
submission. This is the design for slice H5 of
|
||||
[#12827](https://github.com/QwenLM/qwen-code/issues/12827), stage H of the
|
||||
Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380).
|
||||
It builds on the task contract of H0a
|
||||
|
|
@ -209,25 +210,66 @@ The facts below are from `main` at `5ddfacc9d4`.
|
|||
`send` result; SMTP provides no delivery query, so outbound is governed
|
||||
by decision 5.
|
||||
|
||||
## Record bodies (H5a contract direction)
|
||||
## Record bodies (H5a)
|
||||
|
||||
Both bodies embed the H0b run block unchanged. The closed field sets,
|
||||
validators and transition rules are pinned by the H5a change in the shared
|
||||
schema and fixture files that TypeScript and Java both replay. This section
|
||||
fixes the direction, not the byte-level schema.
|
||||
Both bodies embed the H0b run block unchanged, both register in
|
||||
`MANAGED_EXTENSION_RECORD_BODIES` with `taskKind: null` — a Channel
|
||||
route or delivery is not a Session task — and both stay absent from
|
||||
`MANAGED_SESSION_ENABLED_DOMAINS`. The byte-level contract lives in
|
||||
`packages/core/src/managed-runtime/managed-channel-record.ts`, is mirrored
|
||||
by `ManagedChannelRecords` in `packages/sdk-java/managed-agent-server`, and
|
||||
is pinned by the shared corpus
|
||||
`contracts/managed-channel-record-v1.fixtures.json` (92 shape cases and 57
|
||||
successor cases), which TypeScript and Java replay identically.
|
||||
|
||||
- `managed-channel_route` (chain identity `routeId`): the channel instance
|
||||
identity, the account identity and its current generation, the
|
||||
route-binding revision, `rootSessionId`/`sessionId` of the bound Session,
|
||||
and the admission policy reference (allowed senders, gates) as a durable
|
||||
configuration pin. Its run tracks the binding's lifecycle only; a route
|
||||
has no execution line.
|
||||
- `managed-channel_delivery` (chain identity `deliveryId`): the route and
|
||||
its revision, the producing Session and Turn references, the `replyRef`
|
||||
or result Artifact references, the segment plan (`segmentId`/`ordinal`
|
||||
layout), per-segment receipt state, and the delivery line of target
|
||||
`channel` from H0b. Terminal receipts commit before the delivery line
|
||||
leaves `sending`/`partial`.
|
||||
- `managed-channel_route` (chain identity `routeId`): closed keys
|
||||
`routeId`, `channelInstanceId`, `accountId`, `accountGeneration`,
|
||||
`routeRevision`, `rootSessionId`, `sessionId`, `scope`, `policyRef`,
|
||||
`run`. The scope carries exactly what the Legacy routing key derives from
|
||||
its kind: `user` keys on the sender inside its chat (`senderId` and
|
||||
`chatId` set, `threadId` null); `thread` keys on the thread, falling back
|
||||
to the chat (exactly one of `threadId`/`chatId` set, `senderId` null);
|
||||
`chat_thread` keys on the chat and only refines to one of its threads
|
||||
(`chatId` set, `threadId` optional, `senderId` null); `single` keys on
|
||||
the instance alone (all three null). The run pins `effectId: routeId` and
|
||||
nothing else: no definition, execution, Runtime, dispatch or delivery —
|
||||
the binding's lifecycle is the only state.
|
||||
- Route successor: the identity (`routeId`, `channelInstanceId`,
|
||||
`accountId`, `scope`) never changes. At the same `routeRevision` the
|
||||
rebind set — `accountGeneration`, `rootSessionId`, `sessionId`,
|
||||
`policyRef` — is byte-identical; a rebind or rollover raises
|
||||
`routeRevision` by exactly one and may then change the set, except that
|
||||
`accountGeneration` never moves backwards (decision 6). A terminal run
|
||||
freezes the record.
|
||||
- `managed-channel_delivery` (chain identity `deliveryId`): closed keys
|
||||
`deliveryId`, `routeId`, `routeRevision`, `sourceTurnId`, `contentRef`,
|
||||
`segments`, `cancelRequested`, `run`. The segment plan carries 1–64
|
||||
segments of `{segmentId, ordinal, contentRef, receipt}` with distinct
|
||||
`segmentId`s and ordinals dense from zero; a receipt is
|
||||
`{providerMessageId, acceptedAt, proofRef|null}`. The run pins
|
||||
`effectId`/`deliveryId` to the chain identity and carries the H0b
|
||||
delivery line of target `channel`, nothing else.
|
||||
- Delivery consistency: the delivery line, the run line and the settled
|
||||
receipts agree at every revision — `planned` with the run `admitted` and
|
||||
no receipts; `sending` or `partial` with the run in flight (`running`,
|
||||
`waiting` or `recovery_blocked`) and, for `partial`, at least one
|
||||
receipt settled but still short of full; `delivered` with the run
|
||||
`settled` and every receipt set;
|
||||
`unknown` exactly with the run `waiting` without a reason (the provider
|
||||
may hold the rest); `rejected` with the run `failed`; `cancelled` with
|
||||
the run `cancelled` and no receipts. A blocked run blocks only while the
|
||||
delivery is provably `sending`/`partial`; given the run pins, the only
|
||||
recovery reason that parses is `handler_unavailable`.
|
||||
- Delivery successor: the plan, the producing Turn and the pinned route
|
||||
revision never change; each segment's receipt is set once and never
|
||||
rewritten; `cancelRequested` is never revoked. The H0b line steps then
|
||||
rule the rest: an `unknown` delivery never returns to `sending` (a
|
||||
resend is a new `deliveryId`, decision 5), and a terminal run freezes
|
||||
the record, so an already-delivered message cannot be edited after the
|
||||
fact, while late receipts that resolve an `unknown` stay commitable. To
|
||||
keep that invariant against the two-step path `unknown → partial →
|
||||
sending`, leaving `unknown` for `partial` itself takes proof: the
|
||||
`partial` revision must settle a segment the `unknown` one did not.
|
||||
- The ingress dedupe tuple of decision 2 is recorded with the admitted
|
||||
input (the `input.accepted` content), and a redelivery under a committed
|
||||
tuple answers with the original `inputId` without a new turn — the exact
|
||||
|
|
@ -236,11 +278,11 @@ fixes the direction, not the byte-level schema.
|
|||
|
||||
## Slice plan
|
||||
|
||||
| Slice | Scope | Exit gates |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| H5a | Record contract: both bodies, validators, fixed-field rules and transition witnesses, added to the shared schema and `managed-extension-record-v1` fixtures; `MANAGED_EXTENSION_RECORD_BODIES` entries; Java replay. | TypeScript and Java produce and refuse identical chains from the fixtures. Both domains stay absent from `MANAGED_SESSION_ENABLED_DOMAINS`; `commitExtensionRecord` still refuses them. The Java store ships the bodies before any writer can commit (H0c open question 7). No production caller constructs either body. |
|
||||
| H5b | Email inbound vertical: `channel_route` commitment, ingress dedupe on the four-part identity, attachment → Artifact staging, routed input admission with wake. Domains enabled for submission for this adapter only. | A provider redelivery commits one input; two identical-text messages commit two. An admission crash leaves staged bytes retained and the original `inputId` queryable; no event is admitted twice and none is silently dropped. A `uidValidity` rollover takes a route revision and the old generation admits nothing new. The email adapter passes its existing behavioral suite against the managed path. |
|
||||
| H5c | Email outbound vertical: `channel_delivery` commitment, outbox dispatcher, per-send receipts, `partial`/`unknown` recovery, explicit user resend with a new `deliveryId`. Public `/v1/agent-channels` and `.../deliveries` shapes served. | A reply interrupted mid-send resumes with only proven-unsent work; a post-send disconnect records `delivery_unknown` and never auto-resends; an explicit resend warns about possible duplication. Model completion and external delivery are projected separately, and a Channel send failure never re-runs the model (reference section 14, item 6). The two planned routes move to `partial` with their H5-owned shapes, covered by the API contract test. |
|
||||
| Slice | Scope | Exit gates |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| H5a (done) | Record contract: both bodies, validators, fixed-field rules and transition witnesses, with their own `managed-channel-record-v1` fixture corpus; `MANAGED_EXTENSION_RECORD_BODIES` entries; Java replay. | TypeScript and Java produce and refuse identical chains from the fixtures. Both domains stay absent from `MANAGED_SESSION_ENABLED_DOMAINS`; `commitExtensionRecord` still refuses them. The Java store ships the bodies before any writer can commit (H0c open question 7). No production caller constructs either body. |
|
||||
| H5b | Email inbound vertical: `channel_route` commitment, ingress dedupe on the four-part identity, attachment → Artifact staging, routed input admission with wake. Domains enabled for submission for this adapter only. | A provider redelivery commits one input; two identical-text messages commit two. An admission crash leaves staged bytes retained and the original `inputId` queryable; no event is admitted twice and none is silently dropped. A `uidValidity` rollover takes a route revision and the old generation admits nothing new. The email adapter passes its existing behavioral suite against the managed path. |
|
||||
| H5c | Email outbound vertical: `channel_delivery` commitment, outbox dispatcher, per-send receipts, `partial`/`unknown` recovery, explicit user resend with a new `deliveryId`. Public `/v1/agent-channels` and `.../deliveries` shapes served. | A reply interrupted mid-send resumes with only proven-unsent work; a post-send disconnect records `delivery_unknown` and never auto-resends; an explicit resend warns about possible duplication. Model completion and external delivery are projected separately, and a Channel send failure never re-runs the model (reference section 14, item 6). The two planned routes move to `partial` with their H5-owned shapes, covered by the API contract test. |
|
||||
|
||||
Later H5 slices (not scheduled here): a second adapter (chosen by product
|
||||
priority); card-style segmented surfaces where a provider supports them;
|
||||
|
|
@ -248,11 +290,14 @@ attachment kinds beyond the O2/O3 staging the email slice uses.
|
|||
|
||||
## Validation plan
|
||||
|
||||
- Fixture parity for both bodies, replayed by TypeScript and by Java.
|
||||
- Authority suites for route rollover, dedupe-tuple admission and replay,
|
||||
delivery segment transitions and the `unknown` recovery affordances.
|
||||
- Java store materialization (a Flyway migration after main's V34), with
|
||||
refusal rollback and outbox columns exercised.
|
||||
- Fixture parity for both bodies, replayed by TypeScript and by Java (H5a).
|
||||
- Authority suites for route rollover, replay refusal, chain rebuild and
|
||||
the delivery-line affordances: dispatch, partial, `unknown` and its
|
||||
no-resend rule (H5a). Cross-record checks — a delivery binding to a
|
||||
committed route at its pinned revision — belong to the slice that
|
||||
enforces them (H5b/H5c), like MCP's and Hooks' enablement-time checks.
|
||||
- Java store materialization for the public Channel resources (Flyway
|
||||
V47), exercised when H5c serves them.
|
||||
- Adapter fault injection: redelivery storms, admission-crash windows,
|
||||
send-then-disconnect, generation rollover mid-delivery, and dispatcher
|
||||
restart between segment receipts — each ends in one input per event or a
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[English](2026-10-04-managed-channels.md) | [简体中文](2026-10-04-managed-channels.zh-CN.md)
|
||||
|
||||
状态:设计提案;本文描述的任何能力均未实现,文中提到的 domain 均未开放提交。本文是 [#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 H5 切片设计,属于 Managed Agent 提案 [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的 H 阶段。它建立在 H0a 的任务契约([设计](2026-09-27-managed-agent-task-contract.zh-CN.md))、H0b 的记录契约([设计](2026-09-27-managed-extension-record-contract.zh-CN.md))和 H0c 的 authority([设计](2026-09-27-managed-extension-authority.zh-CN.md))之上。下文的“参考设计”指该提案[扩展运行时设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-extension-runtime.md)的第 1、3、6、11、12、13、14 节;其序言把 Channels 的字段级契约留给[自动化设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-automation.md),均以 #12827 固定的提交为准。
|
||||
状态:H5a 切片(两个 domain 的记录契约)已实现;H5b 与 H5c 切片仍为设计提案,文中提到的 domain 均未开放提交。本文是 [#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 H5 切片设计,属于 Managed Agent 提案 [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的 H 阶段。它建立在 H0a 的任务契约([设计](2026-09-27-managed-agent-task-contract.zh-CN.md))、H0b 的记录契约([设计](2026-09-27-managed-extension-record-contract.zh-CN.md))和 H0c 的 authority([设计](2026-09-27-managed-extension-authority.zh-CN.md))之上。下文的“参考设计”指该提案[扩展运行时设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-extension-runtime.md)的第 1、3、6、11、12、13、14 节;其序言把 Channels 的字段级契约留给[自动化设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-automation.md),均以 #12827 固定的提交为准。
|
||||
|
||||
## 问题
|
||||
|
||||
|
|
@ -59,29 +59,32 @@ Legacy channel(`packages/channels/*`)的适配器运行在 daemon 或 CLI
|
|||
7. **普通聊天仍作为 Session input 进入。** 命中的入站消息按 H0c 的机制准入其 Session——同一事务携带输入及其唤醒——reply target 就是路由绑定的那个,并随输入一并提交。之后服务该交付的适配器读取已提交的 target;它不能替换成 UI 选中的 Session。
|
||||
8. **email 适配器是参考垂直切片。** 首先移植它,是因为它的 Legacy 状态已经把契约需要的四个概念分开:邮箱代数(`uidValidity`)、平台事件身份(`uidValidity:uid`、基于 Message-ID 的摘要)、在途不确定性(`pending` / `outboundPending`)与回复路由。本切片把它们逐一映射到 `channel_route` 与 `channel_delivery` 事实,不发明代码中不存在的 provider API:IMAP 轮询与 `uidValidity` 来自 `imapflow`,Message-ID 解析来自 `mailparser`,外发接受来自 nodemailer 的 SMTP `send` 结果;SMTP 不提供送达查询,因此外发由决策 5 管辖。
|
||||
|
||||
## 记录正文(H5a 契约方向)
|
||||
## 记录正文(H5a)
|
||||
|
||||
两个正文都原样嵌入 H0b 运行块。封闭的字段集、验证器与迁移规则由 H5a 变更在共用 schema 与 fixture 文件中固定,TypeScript 与 Java 双方回放。本节确定方向,不固定字节级 schema。
|
||||
两个正文都原样嵌入 H0b 运行块,都以 `taskKind: null` 注册进 `MANAGED_EXTENSION_RECORD_BODIES`——Channel 的 route 与 delivery 不是 Session 任务——并继续缺席 `MANAGED_SESSION_ENABLED_DOMAINS`。字节级契约位于 `packages/core/src/managed-runtime/managed-channel-record.ts`,由 `packages/sdk-java/managed-agent-server` 的 `ManagedChannelRecords` 镜像,并由共用语料 `contracts/managed-channel-record-v1.fixtures.json`(92 个形态用例与 57 个后继用例)固定,TypeScript 与 Java 回放完全一致。
|
||||
|
||||
- `managed-channel_route`(链身份 `routeId`):channel 实例身份、账号身份及其当前 generation、路由绑定修订、被绑定 Session 的 `rootSessionId`/`sessionId`,以及作为持久配置钉的准入策略引用(允许的发送者、门禁)。其运行只跟踪绑定的生命周期;路由没有执行状态线。
|
||||
- `managed-channel_delivery`(链身份 `deliveryId`):路由及其修订、产出 Session 与回合引用、`replyRef` 或结果 Artifact 引用、分段计划(`segmentId`/`ordinal` 布局)、逐段回执状态,以及 H0b 目标 `channel` 的交付状态线。终态回执先于交付状态线离开 `sending`/`partial` 提交。
|
||||
- `managed-channel_route`(链身份 `routeId`):封闭键 `routeId`、`channelInstanceId`、`accountId`、`accountGeneration`、`routeRevision`、`rootSessionId`、`sessionId`、`scope`、`policyRef`、`run`。scope 按 kind 携带 Legacy 路由键实际派生的身份:`user` 以 chat 内的发送者为键(`senderId` 与 `chatId` 必填,`threadId` 为 null);`thread` 以 thread 为键并回退到 chat(`threadId`/`chatId` 恰填其一,`senderId` 为 null);`chat_thread` 以 chat 为键、仅可细化到其一个 thread(`chatId` 必填,`threadId` 可填,`senderId` 为 null);`single` 仅以实例为键(三者全 null)。运行只钉 `effectId: routeId`:无 definition、execution、Runtime、dispatch 或 delivery——绑定的生命周期是唯一状态。
|
||||
- route 后继:身份(`routeId`、`channelInstanceId`、`accountId`、`scope`)不变。`routeRevision` 相同时重绑定集合——`accountGeneration`、`rootSessionId`、`sessionId`、`policyRef`——逐字节相同;重绑定或换代把 `routeRevision` 恰好加一,并且此时才能改动集合,但 `accountGeneration` 绝不后退(决策 6)。运行终态冻结整条记录。
|
||||
- `managed-channel_delivery`(链身份 `deliveryId`):封闭键 `deliveryId`、`routeId`、`routeRevision`、`sourceTurnId`、`contentRef`、`segments`、`cancelRequested`、`run`。分段计划携带 1–64 个 `{segmentId, ordinal, contentRef, receipt}` 段,`segmentId` 互不相同,`ordinal` 从 0 连续;回执为 `{providerMessageId, acceptedAt, proofRef|null}`。运行把 `effectId`/`deliveryId` 钉为链身份,并携带 H0b 目标 `channel` 的交付状态线,其余为空。
|
||||
- delivery 一致性:交付状态线、运行状态线与已回执分段在每个修订上一致——`planned` 对应运行 `admitted` 且无回执;`sending`/`partial` 对应运行在途(`running`、`waiting` 或 `recovery_blocked`),其中 `partial` 至少已回执一段但仍至少差一段未齐;`delivered` 对应运行 `settled` 且回执全齐;`unknown` 恰对应运行无原因的 `waiting`(provider 可能已持有其余分段);`rejected` 对应运行 `failed`;`cancelled` 对应运行 `cancelled` 且无回执。受阻运行只在交付可证为 `sending`/`partial` 时受阻;在运行的钉约束下,唯一能解析的恢复原因是 `handler_unavailable`。
|
||||
- delivery 后继:计划、产出回合与钉住的路由修订不变;每段回执只写一次、绝不改写;`cancelRequested` 绝不撤销。其余由 H0b 状态线步进约束:`unknown` 的交付绝不回到 `sending`(重发使用新 `deliveryId`,决策 5);运行终态冻结整条记录,已送达的消息不能事后编辑,而解决 `unknown` 的迟到回执仍可提交。为挡住 `unknown → partial → sending` 的两步绕行,离开 `unknown` 进入 `partial` 本身即要求证据:`partial` 修订必须结清 `unknown` 时尚未结清的至少一个分段。
|
||||
- 决策 2 的入站去重四元组随准入的输入记录(`input.accepted` 的内容);携带已提交四元组的重投以原 `inputId` 回答,不产生新回合——去重索引的确切存储形式是 H5b 的决策,随 email 切片固定。
|
||||
|
||||
## 切片计划
|
||||
|
||||
| 切片 | 范围 | 通过门槛 |
|
||||
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H5a | 记录契约:两个正文、验证器、固定字段规则与迁移见证,加入共用 schema 与 `managed-extension-record-v1` fixture;`MANAGED_EXTENSION_RECORD_BODIES` 条目;Java 回放。 | TypeScript 与 Java 对 fixture 给出并拒绝完全相同的链。两个 domain 继续缺席 `MANAGED_SESSION_ENABLED_DOMAINS`;`commitExtensionRecord` 仍然拒绝它们。Java 存储先于任何写入者提交,携带这两个正文(H0c 未决问题 7)。没有生产调用方构造任一正文。 |
|
||||
| H5b | email 入站垂直切片:`channel_route` 提交、按四元组身份的入站去重、附件 → Artifact 转存、带唤醒的路由输入准入。两个 domain 仅对该适配器开放提交。 | provider 重投只提交一个 input;两条文本相同的消息提交两个。准入崩溃保留 staged bytes 且原 `inputId` 可查;没有事件被准入两次,也没有事件被静默丢弃。`uidValidity` 换代产生路由修订,旧代数不再准入新内容。email 适配器在 Managed 路径上通过其现有行为套件。 |
|
||||
| H5c | email 外发垂直切片:`channel_delivery` 提交、outbox 派发器、逐次发送回执、`partial`/`unknown` 恢复、以新 `deliveryId` 的显式用户重发。公开 `/v1/agent-channels` 与 `.../deliveries` 形状提供。 | 发送中断的回复只补被证明未发送的部分;发送后断线记录 `delivery_unknown` 且绝不自动重发;显式重发提示可能重复。模型完成与外部送达分别投影,Channel 发送失败绝不重跑模型(参考设计第 14 节第 6 条)。两个 planned 路由随其 H5 形状转为 `partial`,并由 API 契约测试覆盖。 |
|
||||
| 切片 | 范围 | 通过门槛 |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H5a(已完成) | 记录契约:两个正文、验证器、固定字段规则与迁移见证,独立成 `managed-channel-record-v1` fixture 语料;`MANAGED_EXTENSION_RECORD_BODIES` 条目;Java 回放。 | TypeScript 与 Java 对 fixture 给出并拒绝完全相同的链。两个 domain 继续缺席 `MANAGED_SESSION_ENABLED_DOMAINS`;`commitExtensionRecord` 仍然拒绝它们。Java 存储先于任何写入者提交,携带这两个正文(H0c 未决问题 7)。没有生产调用方构造任一正文。 |
|
||||
| H5b | email 入站垂直切片:`channel_route` 提交、按四元组身份的入站去重、附件 → Artifact 转存、带唤醒的路由输入准入。两个 domain 仅对该适配器开放提交。 | provider 重投只提交一个 input;两条文本相同的消息提交两个。准入崩溃保留 staged bytes 且原 `inputId` 可查;没有事件被准入两次,也没有事件被静默丢弃。`uidValidity` 换代产生路由修订,旧代数不再准入新内容。email 适配器在 Managed 路径上通过其现有行为套件。 |
|
||||
| H5c | email 外发垂直切片:`channel_delivery` 提交、outbox 派发器、逐次发送回执、`partial`/`unknown` 恢复、以新 `deliveryId` 的显式用户重发。公开 `/v1/agent-channels` 与 `.../deliveries` 形状提供。 | 发送中断的回复只补被证明未发送的部分;发送后断线记录 `delivery_unknown` 且绝不自动重发;显式重发提示可能重复。模型完成与外部送达分别投影,Channel 发送失败绝不重跑模型(参考设计第 14 节第 6 条)。两个 planned 路由随其 H5 形状转为 `partial`,并由 API 契约测试覆盖。 |
|
||||
|
||||
后续 H5 切片(本文不排期):第二个适配器(按产品优先级选择);provider 支持时的卡片式分段界面;超出 email 切片所用 O2/O3 转存的附件种类。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 两个正文的 fixture 一致性,TypeScript 与 Java 双方回放。
|
||||
- authority 套件:路由换代、去重四元组准入与重放、交付分段迁移、`unknown` 恢复入口。
|
||||
- Java 存储物化(main 的 V34 之后的一个 Flyway 迁移),覆盖拒绝回滚与 outbox 列。
|
||||
- 两个正文的 fixture 一致性,TypeScript 与 Java 双方回放(H5a)。
|
||||
- authority 套件:路由换代、重放拒绝、链重建与交付状态线语义:派发、partial、`unknown` 及其禁重发规则(H5a)。跨记录检查——交付按钉住的修订绑定已提交路由——属于执行它们的切片(H5b/H5c),与 MCP、Hooks 的启用期检查一致。
|
||||
- 公开 Channel 资源的 Java 存储物化(Flyway V47),在 H5c 提供这些资源时启用。
|
||||
- 适配器故障注入:重投风暴、准入崩溃窗口、发送后断线、交付中途换代、段回执之间重启派发器——每次最终每事件一个 input,或进入可见的 `partial`/`unknown`,绝不重复发送。
|
||||
- email 参考切片一致性:Managed 路径对已提交记录回放 Legacy 适配器记录在案的行为用例(去重窗口、在途拒绝语义、回复路由)。
|
||||
- 变异检查:每条去重、迁移与恢复规则逐一禁用,并且每一次都有测试失败。
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,9 @@
|
|||
"shell": "background_shell",
|
||||
"child_agent": "child_agent"
|
||||
},
|
||||
"child_acceptance": null
|
||||
"child_acceptance": null,
|
||||
"channel_route": null,
|
||||
"channel_delivery": null
|
||||
},
|
||||
"additionalRecordBodies": {
|
||||
"schedule": null,
|
||||
|
|
|
|||
104
packages/core/src/managed-runtime/managed-channel-record.test.ts
Normal file
104
packages/core/src/managed-runtime/managed-channel-record.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MANAGED_EXTENSION_RECORD_BODIES } from './managed-extension-projection.js';
|
||||
import { MANAGED_SESSION_ENABLED_DOMAINS } from './managed-session-records.js';
|
||||
|
||||
type Domain = 'channel_route' | 'channel_delivery';
|
||||
interface Fixture {
|
||||
id: string;
|
||||
domain: Domain;
|
||||
patch: Record<string, unknown>;
|
||||
valid: boolean;
|
||||
start: boolean;
|
||||
}
|
||||
const fixtures = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./contracts/managed-channel-record-v1.fixtures.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
) as {
|
||||
templates: Record<Domain, Record<string, unknown>>;
|
||||
cases: Fixture[];
|
||||
successors: Array<
|
||||
Omit<Fixture, 'patch' | 'start'> & {
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function merge(
|
||||
base: Record<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const value = structuredClone(base ?? {});
|
||||
for (const [key, replacement] of Object.entries(patch)) {
|
||||
value[key] =
|
||||
replacement !== null &&
|
||||
typeof replacement === 'object' &&
|
||||
!Array.isArray(replacement)
|
||||
? merge(
|
||||
value[key] as Record<string, unknown>,
|
||||
replacement as Record<string, unknown>,
|
||||
)
|
||||
: replacement;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('managed-channel-record/1 shared contract', () => {
|
||||
it('registers both bodies without enabling their domains or projecting tasks', () => {
|
||||
for (const domain of ['channel_route', 'channel_delivery'] as const) {
|
||||
expect(MANAGED_SESSION_ENABLED_DOMAINS).not.toContain(domain);
|
||||
expect(
|
||||
MANAGED_EXTENSION_RECORD_BODIES[domain]!.taskKindOf({}),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(fixtures.cases)('$id', (fixture) => {
|
||||
const body = MANAGED_EXTENSION_RECORD_BODIES[fixture.domain]!;
|
||||
const record = merge(fixtures.templates[fixture.domain], fixture.patch);
|
||||
if (fixture.valid) expect(() => body.parse(record)).not.toThrow();
|
||||
else expect(() => body.parse(record)).toThrow();
|
||||
expect(body.isStart(record)).toBe(fixture.start);
|
||||
});
|
||||
|
||||
it.each(fixtures.successors)('$id', (fixture) => {
|
||||
const template = fixtures.templates[fixture.domain];
|
||||
expect(
|
||||
MANAGED_EXTENSION_RECORD_BODIES[fixture.domain]!.isSuccessor(
|
||||
merge(template, fixture.before),
|
||||
merge(template, fixture.after),
|
||||
),
|
||||
).toBe(fixture.valid);
|
||||
});
|
||||
|
||||
it('rejects a sparse segment plan before publication', () => {
|
||||
const body = MANAGED_EXTENSION_RECORD_BODIES.channel_delivery!;
|
||||
const sparse = structuredClone(fixtures.templates.channel_delivery) as {
|
||||
segments: unknown[];
|
||||
};
|
||||
// One hole of length 1: the index walk must see it as a non-object.
|
||||
sparse.segments = new Array(1) as unknown[];
|
||||
expect(() => body.parse(sparse)).toThrow();
|
||||
expect(body.isStart(sparse)).toBe(false);
|
||||
|
||||
// A hole behind a real segment: deletion leaves the hole, not null.
|
||||
const holed = structuredClone(fixtures.templates.channel_delivery) as {
|
||||
segments: unknown[];
|
||||
};
|
||||
delete holed.segments[1];
|
||||
expect(holed.segments).toHaveLength(2);
|
||||
expect(() => body.parse(holed)).toThrow();
|
||||
});
|
||||
});
|
||||
475
packages/core/src/managed-runtime/managed-channel-record.ts
Normal file
475
packages/core/src/managed-runtime/managed-channel-record.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
isExtensionRunStart,
|
||||
isExtensionRunSuccessor,
|
||||
parseExtensionRun,
|
||||
type ExtensionRun,
|
||||
type ExtensionRunState,
|
||||
} from './managed-extension-record.js';
|
||||
import {
|
||||
assertManagedSessionDurableRef,
|
||||
assertManagedSessionSequence,
|
||||
assertManagedSessionStableId,
|
||||
assertManagedSessionTime,
|
||||
ManagedSessionRecordError,
|
||||
type ManagedSessionDurableRef,
|
||||
type ManagedSessionJsonValue,
|
||||
} from './managed-session-records.js';
|
||||
|
||||
// H5a of #12827: the `managed-channel_route` and `managed-channel_delivery`
|
||||
// record bodies, schema version 1. A route is the durable binding of one
|
||||
// authenticated (instance, account, sender, chat, thread) scope to one
|
||||
// Session; a delivery is one formal result sent out through that binding,
|
||||
// chain-keyed by deliveryId with one receipt per segment. Neither is a
|
||||
// Session task, so both register with taskKind null. Both domains stay
|
||||
// disabled for submission until the slices that ship their producers (H5b
|
||||
// inbound, H5c outbound); the shared fixtures in
|
||||
// contracts/managed-channel-record-v1.fixtures.json pin both validators,
|
||||
// and ManagedChannelRecords in packages/sdk-java/managed-agent-server
|
||||
// replays the same cases.
|
||||
|
||||
/** The route scope kinds, preserving the Legacy router's semantics. */
|
||||
export const CHANNEL_ROUTE_SCOPE_KINDS = [
|
||||
'user',
|
||||
'chat_thread',
|
||||
'thread',
|
||||
'single',
|
||||
] as const;
|
||||
export type ChannelRouteScopeKind = (typeof CHANNEL_ROUTE_SCOPE_KINDS)[number];
|
||||
|
||||
/** The most segments one delivery plan may carry. */
|
||||
export const CHANNEL_DELIVERY_MAX_SEGMENTS = 64;
|
||||
|
||||
export interface ChannelRouteScope {
|
||||
readonly kind: ChannelRouteScopeKind;
|
||||
readonly senderId: string | null;
|
||||
readonly chatId: string | null;
|
||||
readonly threadId: string | null;
|
||||
}
|
||||
|
||||
export interface ChannelRoute {
|
||||
readonly routeId: string;
|
||||
readonly channelInstanceId: string;
|
||||
readonly accountId: string;
|
||||
/** The provider-side account generation: a re-key advances it. */
|
||||
readonly accountGeneration: number;
|
||||
/** The route binding revision: a rebind or rollover advances it. */
|
||||
readonly routeRevision: number;
|
||||
readonly rootSessionId: string;
|
||||
readonly sessionId: string;
|
||||
readonly scope: ChannelRouteScope;
|
||||
/** The admission policy (allowed senders, gates) as a durable pin. */
|
||||
readonly policyRef: ManagedSessionDurableRef;
|
||||
readonly run: ExtensionRun;
|
||||
}
|
||||
|
||||
export interface ChannelDeliveryReceipt {
|
||||
readonly providerMessageId: string;
|
||||
readonly acceptedAt: number;
|
||||
/** The provider's send response, when one was retained. */
|
||||
readonly proofRef: ManagedSessionDurableRef | null;
|
||||
}
|
||||
|
||||
export interface ChannelDeliverySegment {
|
||||
readonly segmentId: string;
|
||||
readonly ordinal: number;
|
||||
readonly contentRef: ManagedSessionDurableRef;
|
||||
readonly receipt: ChannelDeliveryReceipt | null;
|
||||
}
|
||||
|
||||
export interface ChannelDelivery {
|
||||
readonly deliveryId: string;
|
||||
readonly routeId: string;
|
||||
/** The route binding revision the delivery was planned against. */
|
||||
readonly routeRevision: number;
|
||||
readonly sourceTurnId: string;
|
||||
/** The formal result the segments were split from. */
|
||||
readonly contentRef: ManagedSessionDurableRef;
|
||||
readonly segments: readonly ChannelDeliverySegment[];
|
||||
readonly cancelRequested: boolean;
|
||||
readonly run: ExtensionRun;
|
||||
}
|
||||
|
||||
const ROUTE_KEYS = [
|
||||
'routeId',
|
||||
'channelInstanceId',
|
||||
'accountId',
|
||||
'accountGeneration',
|
||||
'routeRevision',
|
||||
'rootSessionId',
|
||||
'sessionId',
|
||||
'scope',
|
||||
'policyRef',
|
||||
'run',
|
||||
] as const;
|
||||
const DELIVERY_KEYS = [
|
||||
'deliveryId',
|
||||
'routeId',
|
||||
'routeRevision',
|
||||
'sourceTurnId',
|
||||
'contentRef',
|
||||
'segments',
|
||||
'cancelRequested',
|
||||
'run',
|
||||
] as const;
|
||||
const SCOPE_KEYS = ['kind', 'senderId', 'chatId', 'threadId'] as const;
|
||||
const SEGMENT_KEYS = ['segmentId', 'ordinal', 'contentRef', 'receipt'] as const;
|
||||
const RECEIPT_KEYS = ['providerMessageId', 'acceptedAt', 'proofRef'] as const;
|
||||
/** The route fields a rebind revision may change, and only then. */
|
||||
const REBIND_KEYS = [
|
||||
'accountGeneration',
|
||||
'routeRevision',
|
||||
'rootSessionId',
|
||||
'sessionId',
|
||||
'policyRef',
|
||||
] as const;
|
||||
|
||||
const TERMINAL_RUN_STATES: readonly ExtensionRunState[] = [
|
||||
'settled',
|
||||
'failed',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new ManagedSessionRecordError(message);
|
||||
}
|
||||
|
||||
function closed<Key extends string>(
|
||||
value: unknown,
|
||||
keys: readonly Key[],
|
||||
): Record<Key, ManagedSessionJsonValue> {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null) ||
|
||||
Object.keys(value).length !== keys.length ||
|
||||
Object.keys(value).some((key) => !keys.includes(key as Key))
|
||||
) {
|
||||
fail(`Channel record must have exactly the keys ${keys.join(', ')}.`);
|
||||
}
|
||||
return { ...value } as Record<Key, ManagedSessionJsonValue>;
|
||||
}
|
||||
|
||||
function id(value: ManagedSessionJsonValue, label: string): string {
|
||||
return assertManagedSessionStableId(value, label);
|
||||
}
|
||||
|
||||
function revision(value: ManagedSessionJsonValue, label: string): number {
|
||||
const parsed = assertManagedSessionSequence(value, label);
|
||||
if (parsed < 1) fail(`${label} must be positive.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function ref(value: ManagedSessionJsonValue, label: string) {
|
||||
return Object.freeze(assertManagedSessionDurableRef(value, label));
|
||||
}
|
||||
|
||||
function parseScope(value: ManagedSessionJsonValue): ChannelRouteScope {
|
||||
const body = closed(value, SCOPE_KEYS);
|
||||
const kind = body.kind;
|
||||
if (
|
||||
kind !== 'user' &&
|
||||
kind !== 'chat_thread' &&
|
||||
kind !== 'thread' &&
|
||||
kind !== 'single'
|
||||
) {
|
||||
fail(
|
||||
'Channel route scope kind must be user, chat_thread, thread or single.',
|
||||
);
|
||||
}
|
||||
const nullableId = (
|
||||
field: ManagedSessionJsonValue,
|
||||
label: string,
|
||||
): string | null => (field === null ? null : id(field, label));
|
||||
const scope: ChannelRouteScope = {
|
||||
kind,
|
||||
senderId: nullableId(body.senderId, 'scope.senderId'),
|
||||
chatId: nullableId(body.chatId, 'scope.chatId'),
|
||||
threadId: nullableId(body.threadId, 'scope.threadId'),
|
||||
};
|
||||
// The carriers the Legacy routing key derives per scope: user keys on the
|
||||
// sender inside its chat, thread keys on the thread when there is one and
|
||||
// on the chat else, chat_thread keys on the chat and only refines to one
|
||||
// of its threads, and single keys on the instance alone.
|
||||
const { senderId, chatId, threadId } = scope;
|
||||
const carries =
|
||||
scope.kind === 'user'
|
||||
? senderId !== null && chatId !== null && threadId === null
|
||||
: scope.kind === 'thread'
|
||||
? senderId === null && (chatId === null) !== (threadId === null)
|
||||
: scope.kind === 'chat_thread'
|
||||
? senderId === null && chatId !== null
|
||||
: senderId === null && chatId === null && threadId === null;
|
||||
if (!carries) {
|
||||
fail(`Channel route scope ${scope.kind} does not carry its identity.`);
|
||||
}
|
||||
return Object.freeze(scope);
|
||||
}
|
||||
|
||||
function routeRun(value: unknown, routeId: string): ExtensionRun {
|
||||
const run = parseExtensionRun(value);
|
||||
if (
|
||||
run.definition !== null ||
|
||||
run.executionCallId !== null ||
|
||||
run.effectId !== routeId ||
|
||||
run.dispatchId !== null ||
|
||||
run.deliveryId !== null ||
|
||||
run.execution !== null ||
|
||||
run.runtime !== null ||
|
||||
run.delivery !== null
|
||||
) {
|
||||
fail('Channel route run must identify its effect and nothing else.');
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
export function parseChannelRoute(value: unknown): ChannelRoute {
|
||||
const body = closed(value, ROUTE_KEYS);
|
||||
const routeId = id(body.routeId, 'routeId');
|
||||
return Object.freeze({
|
||||
routeId,
|
||||
channelInstanceId: id(body.channelInstanceId, 'channelInstanceId'),
|
||||
accountId: id(body.accountId, 'accountId'),
|
||||
accountGeneration: revision(body.accountGeneration, 'accountGeneration'),
|
||||
routeRevision: revision(body.routeRevision, 'routeRevision'),
|
||||
rootSessionId: id(body.rootSessionId, 'rootSessionId'),
|
||||
sessionId: id(body.sessionId, 'sessionId'),
|
||||
scope: parseScope(body.scope),
|
||||
policyRef: ref(body.policyRef, 'policyRef'),
|
||||
run: routeRun(body.run, routeId),
|
||||
});
|
||||
}
|
||||
|
||||
function parseReceipt(value: ManagedSessionJsonValue): ChannelDeliveryReceipt {
|
||||
const body = closed(value, RECEIPT_KEYS);
|
||||
return Object.freeze({
|
||||
providerMessageId: id(body.providerMessageId, 'receipt.providerMessageId'),
|
||||
acceptedAt: assertManagedSessionTime(body.acceptedAt, 'receipt.acceptedAt'),
|
||||
proofRef: body.proofRef === null ? null : ref(body.proofRef, 'proofRef'),
|
||||
});
|
||||
}
|
||||
|
||||
function parseSegments(
|
||||
value: ManagedSessionJsonValue,
|
||||
): readonly ChannelDeliverySegment[] {
|
||||
if (!Array.isArray(value)) fail('Channel delivery segments must be a list.');
|
||||
if (value.length < 1 || value.length > CHANNEL_DELIVERY_MAX_SEGMENTS) {
|
||||
fail(
|
||||
`Channel delivery segments must number 1 to ${CHANNEL_DELIVERY_MAX_SEGMENTS}.`,
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
// Array.prototype.map would silently skip empty slots, letting a sparse
|
||||
// plan through every check and betraying its JSON round-trip when the
|
||||
// holes serialize as null; walk every index so a hole fails as a
|
||||
// non-object entry.
|
||||
const segments: ChannelDeliverySegment[] = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const body = closed(value[index], SEGMENT_KEYS);
|
||||
const segmentId = id(body.segmentId, `segments[${index}].segmentId`);
|
||||
if (seen.has(segmentId)) {
|
||||
fail(`Channel delivery segment ${segmentId} is named twice.`);
|
||||
}
|
||||
seen.add(segmentId);
|
||||
const ordinal = assertManagedSessionSequence(
|
||||
body.ordinal,
|
||||
`segments[${index}].ordinal`,
|
||||
);
|
||||
if (ordinal !== index) {
|
||||
fail('Channel delivery segment ordinals must be dense from zero.');
|
||||
}
|
||||
segments.push(
|
||||
Object.freeze({
|
||||
segmentId,
|
||||
ordinal,
|
||||
contentRef: ref(body.contentRef, `segments[${index}].contentRef`),
|
||||
receipt: body.receipt === null ? null : parseReceipt(body.receipt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(segments);
|
||||
}
|
||||
|
||||
function deliveryRun(value: unknown, deliveryId: string): ExtensionRun {
|
||||
const run = parseExtensionRun(value);
|
||||
if (
|
||||
run.definition !== null ||
|
||||
run.executionCallId !== null ||
|
||||
run.effectId !== deliveryId ||
|
||||
run.dispatchId !== null ||
|
||||
run.deliveryId !== deliveryId ||
|
||||
run.execution !== null ||
|
||||
run.runtime !== null ||
|
||||
run.delivery === null ||
|
||||
run.delivery.target !== 'channel'
|
||||
) {
|
||||
fail(
|
||||
'Channel delivery run must carry its channel delivery line and nothing else.',
|
||||
);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
export function parseChannelDelivery(value: unknown): ChannelDelivery {
|
||||
const body = closed(value, DELIVERY_KEYS);
|
||||
if (typeof body.cancelRequested !== 'boolean') {
|
||||
fail('Channel delivery cancelRequested must be boolean.');
|
||||
}
|
||||
const deliveryId = id(body.deliveryId, 'deliveryId');
|
||||
const run = deliveryRun(body.run, deliveryId);
|
||||
const segments = parseSegments(body.segments);
|
||||
const receipts = segments.map((segment) => segment.receipt !== null);
|
||||
const settled = receipts.filter(Boolean).length;
|
||||
const state = run.delivery!.state;
|
||||
const pins: Record<string, boolean> = {
|
||||
// reserved is out in the pin's favor: a delivery exists only once it is
|
||||
// planned, and the run block refuses a reserved state with a delivery.
|
||||
planned: run.state === 'admitted' && settled === 0,
|
||||
sending:
|
||||
(run.state === 'running' ||
|
||||
run.state === 'waiting' ||
|
||||
run.state === 'recovery_blocked') &&
|
||||
settled < segments.length,
|
||||
partial:
|
||||
(run.state === 'running' ||
|
||||
run.state === 'waiting' ||
|
||||
run.state === 'recovery_blocked') &&
|
||||
settled > 0 &&
|
||||
settled < segments.length,
|
||||
delivered: run.state === 'settled' && settled === segments.length,
|
||||
unknown:
|
||||
run.state === 'waiting' &&
|
||||
run.reason === null &&
|
||||
settled < segments.length,
|
||||
rejected: run.state === 'failed' && settled < segments.length,
|
||||
// A cancellation is always someone's committed act, so the request
|
||||
// flag is part of the cancelled fact.
|
||||
cancelled:
|
||||
run.state === 'cancelled' && settled === 0 && body.cancelRequested,
|
||||
};
|
||||
if (!pins[state]) {
|
||||
fail(
|
||||
`Channel delivery line ${state} does not match its run and segment receipts.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
deliveryId,
|
||||
routeId: id(body.routeId, 'routeId'),
|
||||
routeRevision: revision(body.routeRevision, 'routeRevision'),
|
||||
sourceTurnId: id(body.sourceTurnId, 'sourceTurnId'),
|
||||
contentRef: ref(body.contentRef, 'contentRef'),
|
||||
segments,
|
||||
cancelRequested: body.cancelRequested,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
function accepts(check: () => boolean): boolean {
|
||||
try {
|
||||
return check();
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedSessionRecordError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function isChannelRouteStart(value: unknown): boolean {
|
||||
return accepts(() => isExtensionRunStart(parseChannelRoute(value).run));
|
||||
}
|
||||
|
||||
export function isChannelDeliveryStart(value: unknown): boolean {
|
||||
return accepts(() => {
|
||||
const record = parseChannelDelivery(value);
|
||||
return (
|
||||
isExtensionRunStart(record.run) &&
|
||||
!record.cancelRequested &&
|
||||
record.segments.every((segment) => segment.receipt === null)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isChannelRouteSuccessor(
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
): boolean {
|
||||
return accepts(() => {
|
||||
const before = parseChannelRoute(previous);
|
||||
const after = parseChannelRoute(next);
|
||||
if (
|
||||
!same(before.routeId, after.routeId) ||
|
||||
!same(before.channelInstanceId, after.channelInstanceId) ||
|
||||
!same(before.accountId, after.accountId) ||
|
||||
!same(before.scope, after.scope) ||
|
||||
!isExtensionRunSuccessor(before.run, after.run)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (TERMINAL_RUN_STATES.includes(before.run.state)) {
|
||||
return same(before, after);
|
||||
}
|
||||
if (before.routeRevision === after.routeRevision) {
|
||||
return REBIND_KEYS.every((key) => same(before[key], after[key]));
|
||||
}
|
||||
return (
|
||||
after.routeRevision === before.routeRevision + 1 &&
|
||||
after.accountGeneration >= before.accountGeneration
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isChannelDeliverySuccessor(
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
): boolean {
|
||||
return accepts(() => {
|
||||
const before = parseChannelDelivery(previous);
|
||||
const after = parseChannelDelivery(next);
|
||||
if (
|
||||
before.deliveryId !== after.deliveryId ||
|
||||
before.routeId !== after.routeId ||
|
||||
before.routeRevision !== after.routeRevision ||
|
||||
before.sourceTurnId !== after.sourceTurnId ||
|
||||
!same(before.contentRef, after.contentRef) ||
|
||||
before.segments.length !== after.segments.length ||
|
||||
!before.segments.every((segment, index) => {
|
||||
const other = after.segments[index]!;
|
||||
return (
|
||||
segment.segmentId === other.segmentId &&
|
||||
segment.ordinal === other.ordinal &&
|
||||
same(segment.contentRef, other.contentRef) &&
|
||||
(segment.receipt === null || same(segment.receipt, other.receipt))
|
||||
);
|
||||
}) ||
|
||||
(before.cancelRequested && !after.cancelRequested) ||
|
||||
!isExtensionRunSuccessor(before.run, after.run)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Leaving unknown takes proof: a partial revision must settle a
|
||||
// segment the unknown one did not, or the next sending revision could
|
||||
// re-send a segment the provider may already hold (decision 5).
|
||||
const settledOf = (record: ChannelDelivery) =>
|
||||
record.segments.filter((segment) => segment.receipt !== null).length;
|
||||
if (
|
||||
before.run.delivery!.state === 'unknown' &&
|
||||
after.run.delivery!.state === 'partial' &&
|
||||
settledOf(after) <= settledOf(before)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (TERMINAL_RUN_STATES.includes(before.run.state)) {
|
||||
return same(before, after);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
@ -41,6 +41,14 @@ import {
|
|||
parseChildRun,
|
||||
type AnyChildRun,
|
||||
} from './managed-child-run-record.js';
|
||||
import {
|
||||
isChannelDeliveryStart,
|
||||
isChannelDeliverySuccessor,
|
||||
isChannelRouteStart,
|
||||
isChannelRouteSuccessor,
|
||||
parseChannelDelivery,
|
||||
parseChannelRoute,
|
||||
} from './managed-channel-record.js';
|
||||
import {
|
||||
isAutomationRunStart,
|
||||
isAutomationRunSuccessor,
|
||||
|
|
@ -181,6 +189,24 @@ export const MANAGED_EXTENSION_RECORD_BODIES: Readonly<
|
|||
isStart: isChildRunStart,
|
||||
isSuccessor: isChildRunSuccessor,
|
||||
}),
|
||||
channel_route: Object.freeze({
|
||||
taskKindOf: () => null,
|
||||
parse: (value: unknown) => {
|
||||
const record = parseChannelRoute(value);
|
||||
return { record, recordId: record.routeId, run: record.run };
|
||||
},
|
||||
isStart: isChannelRouteStart,
|
||||
isSuccessor: isChannelRouteSuccessor,
|
||||
}),
|
||||
channel_delivery: Object.freeze({
|
||||
taskKindOf: () => null,
|
||||
parse: (value: unknown) => {
|
||||
const record = parseChannelDelivery(value);
|
||||
return { record, recordId: record.deliveryId, run: record.run };
|
||||
},
|
||||
isStart: isChannelDeliveryStart,
|
||||
isSuccessor: isChannelDeliverySuccessor,
|
||||
}),
|
||||
child_acceptance: Object.freeze({
|
||||
taskKindOf: () => null,
|
||||
parse: (value: unknown) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,680 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SessionWriterLease } from '../services/session-writer-lease.js';
|
||||
import { LocalManagedSessionAuthority } from './managed-session-authority.js';
|
||||
import { LocalManagedSessionResourceStore } from './managed-session-resources.js';
|
||||
import {
|
||||
ManagedSessionRecordError,
|
||||
type ManagedSessionDurableRef,
|
||||
} from './managed-session-records.js';
|
||||
|
||||
// channel_route and channel_delivery stay disabled for submission until the
|
||||
// slices that ship their producers (H5b/H5c); this suite runs the
|
||||
// commit/rebuild path ahead of enablement, like the child_run suite does
|
||||
// for the H3 enablement slice.
|
||||
const enablement = vi.hoisted(() => ({ route: true, delivery: true }));
|
||||
|
||||
vi.mock('./managed-session-records.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./managed-session-records.js')>();
|
||||
return {
|
||||
...actual,
|
||||
assertManagedSessionDomainEnabled: (
|
||||
domain: Parameters<typeof actual.assertManagedSessionDomainEnabled>[0],
|
||||
) => {
|
||||
if (domain === 'channel_route' && enablement.route) return;
|
||||
if (domain === 'channel_delivery' && enablement.delivery) return;
|
||||
actual.assertManagedSessionDomainEnabled(domain);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const temporaryDirectories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
enablement.route = true;
|
||||
enablement.delivery = true;
|
||||
for (const directory of temporaryDirectories) {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
temporaryDirectories.clear();
|
||||
});
|
||||
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const sessionKey = {
|
||||
tenantId: 'tenant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId,
|
||||
};
|
||||
|
||||
interface Harness {
|
||||
readonly runtimeBaseDir: string;
|
||||
readonly transcriptPath: string;
|
||||
readonly store: LocalManagedSessionResourceStore;
|
||||
now: number;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-managed-channel-'),
|
||||
);
|
||||
temporaryDirectories.add(root);
|
||||
const runtimeBaseDir = path.join(root, 'runtime');
|
||||
const transcriptPath = path.join(root, 'chats', `${sessionId}.jsonl`);
|
||||
await fs.mkdir(runtimeBaseDir, { recursive: true });
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
return {
|
||||
runtimeBaseDir,
|
||||
transcriptPath,
|
||||
store: LocalManagedSessionResourceStore.create({
|
||||
runtimeBaseDir,
|
||||
sessionKey,
|
||||
}),
|
||||
now: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
async function withAuthority<T>(
|
||||
harness: Harness,
|
||||
run: (authority: LocalManagedSessionAuthority) => Promise<T>,
|
||||
options: { create?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const lease = await SessionWriterLease.acquire({
|
||||
runtimeBaseDir: harness.runtimeBaseDir,
|
||||
sessionId,
|
||||
transcriptPath: harness.transcriptPath,
|
||||
});
|
||||
try {
|
||||
const create =
|
||||
options.create === false
|
||||
? undefined
|
||||
: {
|
||||
definitionRef: await harness.store.publish(
|
||||
'managed-definition',
|
||||
Buffer.from('{}', 'utf8'),
|
||||
),
|
||||
rootSnapshotRef: await harness.store.publish(
|
||||
'managed-root',
|
||||
Buffer.from('{}', 'utf8'),
|
||||
),
|
||||
createdBy: 'daemon',
|
||||
};
|
||||
const authority = await LocalManagedSessionAuthority.open({
|
||||
lease,
|
||||
sessionKey,
|
||||
now: () => harness.now,
|
||||
resources: harness.store,
|
||||
cwd: '/workspace',
|
||||
version: 'test',
|
||||
...(create === undefined ? {} : { create }),
|
||||
});
|
||||
return await run(authority);
|
||||
} finally {
|
||||
await lease.release().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelRefs {
|
||||
readonly policy: ManagedSessionDurableRef;
|
||||
readonly result: ManagedSessionDurableRef;
|
||||
readonly seg1: ManagedSessionDurableRef;
|
||||
readonly seg2: ManagedSessionDurableRef;
|
||||
readonly proof: ManagedSessionDurableRef;
|
||||
}
|
||||
|
||||
// The writer-side closure reads every reference a body names, so the test
|
||||
// publishes real content and builds records from the returned refs.
|
||||
async function publishRefs(harness: Harness): Promise<ChannelRefs> {
|
||||
return {
|
||||
policy: await harness.store.publish(
|
||||
'channel-policy',
|
||||
Buffer.from('{"allowed":["sender-1"]}', 'utf8'),
|
||||
),
|
||||
result: await harness.store.publish(
|
||||
'managed-tool-result',
|
||||
Buffer.from('{"text":"ok"}', 'utf8'),
|
||||
),
|
||||
seg1: await harness.store.publish(
|
||||
'channel-delivery-segment',
|
||||
Buffer.from('part one', 'utf8'),
|
||||
),
|
||||
seg2: await harness.store.publish(
|
||||
'channel-delivery-segment',
|
||||
Buffer.from('part two', 'utf8'),
|
||||
),
|
||||
proof: await harness.store.publish(
|
||||
'channel-delivery-receipt',
|
||||
Buffer.from('{"250":"ok"}', 'utf8'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const RECEIPT_1 = {
|
||||
providerMessageId: 'provider-msg-1',
|
||||
acceptedAt: 1_750_000_000_000,
|
||||
proofRef: null,
|
||||
};
|
||||
|
||||
function route(
|
||||
refs: ChannelRefs,
|
||||
overrides: Record<string, unknown> = {},
|
||||
runOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
routeId: 'route-1',
|
||||
channelInstanceId: 'instance-1',
|
||||
accountId: 'account-1',
|
||||
accountGeneration: 7,
|
||||
routeRevision: 3,
|
||||
rootSessionId: sessionId,
|
||||
sessionId,
|
||||
scope: {
|
||||
kind: 'user',
|
||||
senderId: 'sender-1',
|
||||
chatId: 'chat-1',
|
||||
threadId: null,
|
||||
},
|
||||
policyRef: refs.policy,
|
||||
run: {
|
||||
state: 'admitted',
|
||||
reason: null,
|
||||
definition: null,
|
||||
executionCallId: null,
|
||||
effectId: 'route-1',
|
||||
dispatchId: null,
|
||||
deliveryId: null,
|
||||
execution: null,
|
||||
runtime: null,
|
||||
delivery: null,
|
||||
...runOverrides,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function delivery(
|
||||
refs: ChannelRefs,
|
||||
receipts: ReadonlyArray<object | null>,
|
||||
runState: string,
|
||||
lineState: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
runOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
deliveryId: 'delivery-1',
|
||||
routeId: 'route-1',
|
||||
routeRevision: 3,
|
||||
sourceTurnId: 'turn-1',
|
||||
contentRef: refs.result,
|
||||
segments: [refs.seg1, refs.seg2].map((contentRef, index) => ({
|
||||
segmentId: `seg-${index + 1}`,
|
||||
ordinal: index,
|
||||
contentRef,
|
||||
receipt: receipts[index] ?? null,
|
||||
})),
|
||||
cancelRequested: false,
|
||||
run: {
|
||||
state: runState,
|
||||
reason: null,
|
||||
definition: null,
|
||||
executionCallId: null,
|
||||
effectId: 'delivery-1',
|
||||
dispatchId: null,
|
||||
deliveryId: 'delivery-1',
|
||||
execution: null,
|
||||
runtime: null,
|
||||
delivery: { target: 'channel', state: lineState },
|
||||
...runOverrides,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function command(
|
||||
domain: 'channel_route' | 'channel_delivery',
|
||||
commandId: string,
|
||||
) {
|
||||
return {
|
||||
operation:
|
||||
domain === 'channel_route'
|
||||
? 'commitChannelRoute'
|
||||
: 'commitChannelDelivery',
|
||||
commandId,
|
||||
sessionKey,
|
||||
contentDigest: 'd'.repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
const TRUSTED = { class: 'trusted_entry' } as const;
|
||||
|
||||
describe('managed session authority channel records', () => {
|
||||
it('chains route revisions including a generation rollover and projects no task', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
const first = await authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:1'),
|
||||
{ domain: 'channel_route', record: route(refs) },
|
||||
TRUSTED,
|
||||
);
|
||||
expect(first).toMatchObject({
|
||||
domain: 'channel_route',
|
||||
recordId: 'route-1',
|
||||
revision: 1,
|
||||
receipt: { replayed: false },
|
||||
});
|
||||
expect(first.recordRef.kind).toBe('managed-channel_route');
|
||||
expect(authority.taskViews()).toEqual([]);
|
||||
|
||||
harness.now = 2_000;
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:2'),
|
||||
{
|
||||
domain: 'channel_route',
|
||||
record: route(refs, {}, { state: 'running' }),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
// A provider re-key rolls the account generation forward with the
|
||||
// binding revision; the old generation admits nothing new because the
|
||||
// chain keying forbids a rollback.
|
||||
harness.now = 3_000;
|
||||
const rolled = await authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:3'),
|
||||
{
|
||||
domain: 'channel_route',
|
||||
record: route(
|
||||
refs,
|
||||
{ routeRevision: 4, accountGeneration: 8 },
|
||||
{ state: 'running' },
|
||||
),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
expect(rolled.revision).toBe(3);
|
||||
expect(
|
||||
authority.extensionRecord('channel_route', 'route-1'),
|
||||
).toMatchObject({
|
||||
revision: 3,
|
||||
operationId: 'route-1:1',
|
||||
});
|
||||
expect(authority.taskViews()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a route rollover that moves the generation backwards', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:1'),
|
||||
{ domain: 'channel_route', record: route(refs) },
|
||||
TRUSTED,
|
||||
);
|
||||
const before = authority.committedSequence;
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:2'),
|
||||
{
|
||||
domain: 'channel_route',
|
||||
record: route(refs, { routeRevision: 4, accountGeneration: 6 }),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/cannot follow|successor|revision/im);
|
||||
expect(authority.committedSequence).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a same-revision rebind', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:1'),
|
||||
{ domain: 'channel_route', record: route(refs) },
|
||||
TRUSTED,
|
||||
);
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:2'),
|
||||
{
|
||||
domain: 'channel_route',
|
||||
record: route(refs, { sessionId: 'session-2' }),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/cannot follow|successor|revision/im);
|
||||
});
|
||||
});
|
||||
|
||||
it('chains a delivery from plan through a lost ACK to proven delivery', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:1'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [null, null], 'admitted', 'planned'),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
harness.now = 2_000;
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:2'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [RECEIPT_1, null], 'running', 'sending'),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
// The second segment's ACK is lost: the record says the provider may
|
||||
// hold it, and nothing may resend it.
|
||||
harness.now = 3_000;
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:3'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [RECEIPT_1, null], 'waiting', 'unknown'),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
harness.now = 4_000;
|
||||
const last = await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:4'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(
|
||||
refs,
|
||||
[RECEIPT_1, { ...RECEIPT_1, providerMessageId: 'provider-msg-2' }],
|
||||
'settled',
|
||||
'delivered',
|
||||
),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
expect(last.revision).toBe(4);
|
||||
expect(authority.taskViews()).toEqual([]);
|
||||
expect(
|
||||
authority.extensionRecord('channel_delivery', 'delivery-1'),
|
||||
).toMatchObject({ revision: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
it('never lets an unknown delivery return to sending', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
for (const [index, record] of [
|
||||
delivery(refs, [null, null], 'admitted', 'planned'),
|
||||
delivery(refs, [RECEIPT_1, null], 'running', 'sending'),
|
||||
delivery(refs, [RECEIPT_1, null], 'waiting', 'unknown'),
|
||||
].entries()) {
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_delivery', `delivery-1:${index + 1}`),
|
||||
{ domain: 'channel_delivery', record },
|
||||
TRUSTED,
|
||||
);
|
||||
}
|
||||
const before = authority.committedSequence;
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:4'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [RECEIPT_1, null], 'running', 'sending'),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/cannot follow|successor|revision/im);
|
||||
expect(authority.committedSequence).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
it('rebuilds both chains on reopen and replays a repeated command', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:1'),
|
||||
{ domain: 'channel_route', record: route(refs) },
|
||||
TRUSTED,
|
||||
);
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:1'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [null, null], 'admitted', 'planned'),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
const replay = await authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:1'),
|
||||
{ domain: 'channel_route', record: route(refs) },
|
||||
TRUSTED,
|
||||
);
|
||||
expect(replay.revision).toBe(1);
|
||||
expect(replay.receipt.replayed).toBe(true);
|
||||
});
|
||||
await withAuthority(
|
||||
harness,
|
||||
async (authority) => {
|
||||
expect(
|
||||
authority.extensionRecord('channel_route', 'route-1'),
|
||||
).toMatchObject({ revision: 1 });
|
||||
expect(
|
||||
authority.extensionRecord('channel_delivery', 'delivery-1'),
|
||||
).toMatchObject({ revision: 1 });
|
||||
expect(authority.taskViews()).toEqual([]);
|
||||
},
|
||||
{ create: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a record whose reference was never published', async () => {
|
||||
const harness = await createHarness();
|
||||
const unpublished: ManagedSessionDurableRef = {
|
||||
resourceId: 'never-published',
|
||||
kind: 'channel-delivery-segment',
|
||||
schemaVersion: 1,
|
||||
byteLength: 2,
|
||||
digest: 'f'.repeat(64),
|
||||
};
|
||||
const refs: ChannelRefs = {
|
||||
policy: unpublished,
|
||||
result: unpublished,
|
||||
seg1: unpublished,
|
||||
seg2: unpublished,
|
||||
proof: unpublished,
|
||||
};
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:1'),
|
||||
{ domain: 'channel_route', record: route(refs) },
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/never-published|channel-policy|resource|reference/im);
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:1'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [null, null], 'admitted', 'planned'),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/never-published|channel-delivery-segment|resource|reference/im,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('commits a delivery whose receipt carries a proof reference', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:1'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [null, null], 'admitted', 'planned'),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
harness.now = 2_000;
|
||||
await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:2'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(
|
||||
refs,
|
||||
[{ ...RECEIPT_1, proofRef: refs.proof }, null],
|
||||
'running',
|
||||
'sending',
|
||||
),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
harness.now = 3_000;
|
||||
const last = await authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:3'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(
|
||||
refs,
|
||||
[
|
||||
{ ...RECEIPT_1, proofRef: refs.proof },
|
||||
{ ...RECEIPT_1, providerMessageId: 'provider-msg-2' },
|
||||
],
|
||||
'settled',
|
||||
'delivered',
|
||||
),
|
||||
},
|
||||
TRUSTED,
|
||||
);
|
||||
expect(last.revision).toBe(3);
|
||||
});
|
||||
await withAuthority(
|
||||
harness,
|
||||
async (authority) => {
|
||||
expect(
|
||||
authority.extensionRecord('channel_delivery', 'delivery-1'),
|
||||
).toMatchObject({ revision: 3 });
|
||||
},
|
||||
{ create: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses each unpublished reference kind on its own', async () => {
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
const unpublished = (resourceId: string, kind: string) => ({
|
||||
resourceId,
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
byteLength: 2,
|
||||
digest: 'f'.repeat(64),
|
||||
});
|
||||
await withAuthority(harness, async (authority) => {
|
||||
// Only the formal result is missing.
|
||||
const noResult = {
|
||||
...refs,
|
||||
result: unpublished('no-result', 'managed-tool-result'),
|
||||
};
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-no-result'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(noResult, [null, null], 'admitted', 'planned'),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/no-result|resource|reference/im);
|
||||
// Only one segment's bytes are missing.
|
||||
const noSeg = {
|
||||
...refs,
|
||||
seg2: unpublished('no-seg-2', 'channel-delivery-segment'),
|
||||
};
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-no-seg'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(noSeg, [null, null], 'admitted', 'planned'),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/no-seg-2|resource|reference/im);
|
||||
// Only the receipt's proof is missing.
|
||||
const noProof = {
|
||||
...refs,
|
||||
proof: unpublished('no-proof', 'channel-delivery-receipt'),
|
||||
};
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-no-proof'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(
|
||||
noProof,
|
||||
[{ ...RECEIPT_1, proofRef: noProof.proof }, null],
|
||||
'running',
|
||||
'sending',
|
||||
),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/no-proof|resource|reference/im);
|
||||
// Only the route's policy is missing.
|
||||
const noPolicy = {
|
||||
...refs,
|
||||
policy: unpublished('no-policy', 'channel-policy'),
|
||||
};
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-no-policy'),
|
||||
{ domain: 'channel_route', record: route(noPolicy) },
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(/no-policy|resource|reference/im);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses both domains while they stay disabled', async () => {
|
||||
enablement.route = false;
|
||||
enablement.delivery = false;
|
||||
const harness = await createHarness();
|
||||
const refs = await publishRefs(harness);
|
||||
await withAuthority(harness, async (authority) => {
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_route', 'route-1:1'),
|
||||
{ domain: 'channel_route', record: route(refs) },
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(ManagedSessionRecordError);
|
||||
await expect(
|
||||
authority.commitExtensionRecord(
|
||||
command('channel_delivery', 'delivery-1:1'),
|
||||
{
|
||||
domain: 'channel_delivery',
|
||||
record: delivery(refs, [null, null], 'admitted', 'planned'),
|
||||
},
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow(ManagedSessionRecordError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -71,6 +71,10 @@ import {
|
|||
parseHookExecution,
|
||||
} from './managed-hook-record.js';
|
||||
import { parseChildRun } from './managed-child-run-record.js';
|
||||
import {
|
||||
parseChannelDelivery,
|
||||
parseChannelRoute,
|
||||
} from './managed-channel-record.js';
|
||||
import {
|
||||
parseChildAcceptance,
|
||||
type ChildAcceptance,
|
||||
|
|
@ -2093,6 +2097,17 @@ export class LocalManagedSessionAuthority {
|
|||
monitor.outputRef,
|
||||
monitor.lastObservationRef,
|
||||
];
|
||||
} else if (domain === 'channel_route') {
|
||||
refs = [parseChannelRoute(record).policyRef];
|
||||
} else if (domain === 'channel_delivery') {
|
||||
const delivery = parseChannelDelivery(record);
|
||||
refs = [
|
||||
delivery.contentRef,
|
||||
...delivery.segments.flatMap((segment) => [
|
||||
segment.contentRef,
|
||||
segment.receipt?.proofRef ?? null,
|
||||
]),
|
||||
];
|
||||
}
|
||||
// Every read settles before a failure is reported, so none outlives
|
||||
// the commit or the open it belongs to.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords.InvalidRecordException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* The Channel domain bodies (H5a of #12827): {@code managed-channel_route}
|
||||
* is the durable binding of one authenticated (instance, account, sender,
|
||||
* chat, thread) scope to one Session; {@code managed-channel_delivery} is
|
||||
* one formal result sent out through that binding, with one receipt per
|
||||
* segment. The shared fixtures in packages/core pin both validators, and
|
||||
* managed-channel-record.ts there replays the same cases. Both domains stay
|
||||
* disabled for submission until the slices that ship their producers.
|
||||
*/
|
||||
public final class ManagedChannelRecords {
|
||||
public static final int MAX_DELIVERY_SEGMENTS = 64;
|
||||
|
||||
private static final Set<String> ROUTE_KEYS = Set.of("routeId",
|
||||
"channelInstanceId", "accountId", "accountGeneration",
|
||||
"routeRevision", "rootSessionId", "sessionId", "scope",
|
||||
"policyRef", "run");
|
||||
private static final Set<String> DELIVERY_KEYS = Set.of("deliveryId",
|
||||
"routeId", "routeRevision", "sourceTurnId", "contentRef",
|
||||
"segments", "cancelRequested", "run");
|
||||
private static final Set<String> SCOPE_KEYS = Set.of("kind", "senderId",
|
||||
"chatId", "threadId");
|
||||
private static final Set<String> SEGMENT_KEYS = Set.of("segmentId",
|
||||
"ordinal", "contentRef", "receipt");
|
||||
private static final Set<String> RECEIPT_KEYS = Set.of(
|
||||
"providerMessageId", "acceptedAt", "proofRef");
|
||||
/** The route fields a rebind revision may change, and only then. */
|
||||
private static final List<String> REBIND_KEYS = List.of(
|
||||
"accountGeneration", "routeRevision", "rootSessionId",
|
||||
"sessionId", "policyRef");
|
||||
|
||||
private ManagedChannelRecords() {
|
||||
}
|
||||
|
||||
/** Checks the body of a managed-channel_route domain record. */
|
||||
public static void requireRoute(JsonNode record) {
|
||||
ManagedExtensionRecords.closed(record, ROUTE_KEYS, "channelRoute");
|
||||
String routeId = ManagedExtensionRecords.id(record.get("routeId"),
|
||||
"channelRoute.routeId");
|
||||
ManagedExtensionRecords.id(record.get("channelInstanceId"),
|
||||
"channelInstanceId");
|
||||
ManagedExtensionRecords.id(record.get("accountId"), "accountId");
|
||||
ManagedExtensionRecords.count(record.get("accountGeneration"), 1,
|
||||
Long.MAX_VALUE, "accountGeneration");
|
||||
ManagedExtensionRecords.count(record.get("routeRevision"), 1,
|
||||
Long.MAX_VALUE, "routeRevision");
|
||||
ManagedExtensionRecords.id(record.get("rootSessionId"),
|
||||
"rootSessionId");
|
||||
ManagedExtensionRecords.id(record.get("sessionId"), "sessionId");
|
||||
requireScope(record.get("scope"));
|
||||
ManagedExtensionRecords.durableRef(record.get("policyRef"),
|
||||
"policyRef");
|
||||
JsonNode run = record.get("run");
|
||||
ManagedExtensionRecords.requireRun(run);
|
||||
require(run.get("definition").isNull()
|
||||
&& run.get("executionCallId").isNull()
|
||||
&& routeId.equals(run.get("effectId").textValue())
|
||||
&& run.get("dispatchId").isNull()
|
||||
&& run.get("deliveryId").isNull()
|
||||
&& run.get("execution").isNull()
|
||||
&& run.get("runtime").isNull()
|
||||
&& run.get("delivery").isNull(),
|
||||
"Channel route run must identify its effect and nothing else");
|
||||
}
|
||||
|
||||
private static void requireScope(JsonNode scope) {
|
||||
ManagedExtensionRecords.closed(scope, SCOPE_KEYS, "channelRoute.scope");
|
||||
String kind = scope.get("kind").textValue();
|
||||
String sender = nullableId(scope.get("senderId"), "scope.senderId");
|
||||
String chat = nullableId(scope.get("chatId"), "scope.chatId");
|
||||
String thread = nullableId(scope.get("threadId"), "scope.threadId");
|
||||
boolean carries = switch (kind == null ? "" : kind) {
|
||||
// The carriers the Legacy routing key derives per scope.
|
||||
case "user" -> sender != null && chat != null && thread == null;
|
||||
case "thread" -> sender == null && (chat == null) != (thread == null);
|
||||
case "chat_thread" -> sender == null && chat != null;
|
||||
case "single" -> sender == null && chat == null && thread == null;
|
||||
default -> false;
|
||||
};
|
||||
require(carries, "Channel route scope " + kind
|
||||
+ " does not carry its identity");
|
||||
}
|
||||
|
||||
/** Checks the body of a managed-channel_delivery domain record. */
|
||||
public static void requireDelivery(JsonNode record) {
|
||||
ManagedExtensionRecords.closed(record, DELIVERY_KEYS,
|
||||
"channelDelivery");
|
||||
String deliveryId = ManagedExtensionRecords.id(
|
||||
record.get("deliveryId"), "channelDelivery.deliveryId");
|
||||
ManagedExtensionRecords.id(record.get("routeId"), "routeId");
|
||||
ManagedExtensionRecords.count(record.get("routeRevision"), 1,
|
||||
Long.MAX_VALUE, "routeRevision");
|
||||
ManagedExtensionRecords.id(record.get("sourceTurnId"),
|
||||
"sourceTurnId");
|
||||
ManagedExtensionRecords.durableRef(record.get("contentRef"),
|
||||
"contentRef");
|
||||
require(record.get("cancelRequested").isBoolean(),
|
||||
"Channel delivery cancelRequested must be boolean");
|
||||
JsonNode segments = record.get("segments");
|
||||
require(segments.isArray() && !segments.isEmpty()
|
||||
&& segments.size() <= MAX_DELIVERY_SEGMENTS,
|
||||
"Channel delivery segments must number 1 to "
|
||||
+ MAX_DELIVERY_SEGMENTS);
|
||||
Set<String> seen = new HashSet<>();
|
||||
int settled = 0;
|
||||
for (int index = 0; index < segments.size(); index++) {
|
||||
JsonNode segment = segments.get(index);
|
||||
ManagedExtensionRecords.closed(segment, SEGMENT_KEYS,
|
||||
"channelDelivery.segments[" + index + "]");
|
||||
String segmentId = ManagedExtensionRecords.id(
|
||||
segment.get("segmentId"), "segmentId");
|
||||
require(seen.add(segmentId), "Channel delivery segment "
|
||||
+ segmentId + " is named twice");
|
||||
require(ManagedExtensionRecords.count(segment.get("ordinal"), 0,
|
||||
Long.MAX_VALUE, "ordinal") == index,
|
||||
"Channel delivery segment ordinals must be dense from zero");
|
||||
ManagedExtensionRecords.durableRef(segment.get("contentRef"),
|
||||
"contentRef");
|
||||
JsonNode receipt = segment.get("receipt");
|
||||
if (!receipt.isNull()) {
|
||||
ManagedExtensionRecords.closed(receipt, RECEIPT_KEYS,
|
||||
"receipt");
|
||||
ManagedExtensionRecords.id(receipt.get("providerMessageId"),
|
||||
"receipt.providerMessageId");
|
||||
ManagedExtensionRecords.count(receipt.get("acceptedAt"), 0,
|
||||
ManagedExtensionRecords.MAX_TIME,
|
||||
"receipt.acceptedAt");
|
||||
if (!receipt.get("proofRef").isNull()) {
|
||||
ManagedExtensionRecords.durableRef(
|
||||
receipt.get("proofRef"), "receipt.proofRef");
|
||||
}
|
||||
settled++;
|
||||
}
|
||||
}
|
||||
JsonNode run = record.get("run");
|
||||
ManagedExtensionRecords.requireRun(run);
|
||||
require(run.get("definition").isNull()
|
||||
&& run.get("executionCallId").isNull()
|
||||
&& deliveryId.equals(run.get("effectId").textValue())
|
||||
&& run.get("dispatchId").isNull()
|
||||
&& deliveryId.equals(run.get("deliveryId").textValue())
|
||||
&& run.get("execution").isNull()
|
||||
&& run.get("runtime").isNull()
|
||||
&& !run.get("delivery").isNull()
|
||||
&& "channel".equals(run.get("delivery").get("target")
|
||||
.textValue()),
|
||||
"Channel delivery run must carry its channel delivery line "
|
||||
+ "and nothing else");
|
||||
String line = run.get("delivery").get("state").textValue();
|
||||
String state = run.get("state").textValue();
|
||||
String reason = run.get("reason").textValue();
|
||||
boolean inFlight = "running".equals(state) || "waiting".equals(state)
|
||||
|| "recovery_blocked".equals(state);
|
||||
boolean pin = switch (line) {
|
||||
// A delivery exists only once it is planned; the run block
|
||||
// refuses a reserved state with a delivery.
|
||||
case "planned" -> "admitted".equals(state) && settled == 0;
|
||||
case "sending" -> inFlight && settled < segments.size();
|
||||
case "partial" -> inFlight && settled > 0
|
||||
&& settled < segments.size();
|
||||
case "delivered" -> "settled".equals(state)
|
||||
&& settled == segments.size();
|
||||
case "unknown" -> "waiting".equals(state) && reason == null
|
||||
&& settled < segments.size();
|
||||
case "rejected" -> "failed".equals(state)
|
||||
&& settled < segments.size();
|
||||
// A cancellation is always someone's committed act, so the
|
||||
// request flag is part of the cancelled fact.
|
||||
case "cancelled" -> "cancelled".equals(state) && settled == 0
|
||||
&& record.get("cancelRequested").booleanValue();
|
||||
default -> false;
|
||||
};
|
||||
require(pin, "Channel delivery line " + line
|
||||
+ " does not match its run and segment receipts");
|
||||
}
|
||||
|
||||
/** Whether {@code record} may be the first revision of a route. */
|
||||
public static boolean isRouteStart(JsonNode record) {
|
||||
return accepts(() -> {
|
||||
requireRoute(record);
|
||||
return ManagedExtensionRecords.isRunStart(record.get("run"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code record} may open a delivery: planned, without a cancel
|
||||
* request, and with no segment proven yet.
|
||||
*/
|
||||
public static boolean isDeliveryStart(JsonNode record) {
|
||||
return accepts(() -> {
|
||||
requireDelivery(record);
|
||||
if (record.get("cancelRequested").booleanValue()) {
|
||||
return false;
|
||||
}
|
||||
for (JsonNode segment : record.get("segments")) {
|
||||
if (!segment.get("receipt").isNull()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return ManagedExtensionRecords.isRunStart(record.get("run"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code next} may follow {@code previous} as the next revision
|
||||
* of one route: its identity and scope are fixed, its run moves
|
||||
* forward, and only a binding revision one higher than the last may
|
||||
* rebind — a rollover never moves the account generation backwards.
|
||||
*/
|
||||
public static boolean isRouteSuccessor(JsonNode previous, JsonNode next) {
|
||||
return accepts(() -> {
|
||||
requireRoute(previous);
|
||||
requireRoute(next);
|
||||
if (!ManagedExtensionRecords.same(previous.get("routeId"),
|
||||
next.get("routeId"))
|
||||
|| !ManagedExtensionRecords.same(previous.get("channelInstanceId"),
|
||||
next.get("channelInstanceId"))
|
||||
|| !ManagedExtensionRecords.same(previous.get("accountId"),
|
||||
next.get("accountId"))
|
||||
|| !ManagedExtensionRecords.same(previous.get("scope"),
|
||||
next.get("scope"))
|
||||
|| !ManagedExtensionRecords.isRunSuccessor(
|
||||
previous.get("run"), next.get("run"))) {
|
||||
return false;
|
||||
}
|
||||
if (ManagedExtensionRecords.TERMINAL.contains(
|
||||
previous.get("run").get("state").textValue())) {
|
||||
return ManagedExtensionRecords.same(previous, next);
|
||||
}
|
||||
long before = previous.get("routeRevision").longValue();
|
||||
long after = next.get("routeRevision").longValue();
|
||||
if (before == after) {
|
||||
for (String key : REBIND_KEYS) {
|
||||
if (!ManagedExtensionRecords.same(previous.get(key),
|
||||
next.get(key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return after == before + 1
|
||||
&& next.get("accountGeneration").longValue() >= previous
|
||||
.get("accountGeneration").longValue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code next} may follow {@code previous} as the next revision
|
||||
* of one delivery: the plan and its pinned route revision are fixed,
|
||||
* each segment's receipt is set once and never rewritten, the cancel
|
||||
* request is never revoked, and an ended delivery changes nothing.
|
||||
*/
|
||||
public static boolean isDeliverySuccessor(JsonNode previous,
|
||||
JsonNode next) {
|
||||
return accepts(() -> {
|
||||
requireDelivery(previous);
|
||||
requireDelivery(next);
|
||||
if (!ManagedExtensionRecords.same(previous.get("deliveryId"),
|
||||
next.get("deliveryId"))
|
||||
|| !ManagedExtensionRecords.same(previous.get("routeId"),
|
||||
next.get("routeId"))
|
||||
|| !ManagedExtensionRecords.same(previous.get("routeRevision"),
|
||||
next.get("routeRevision"))
|
||||
|| !ManagedExtensionRecords.same(previous.get("sourceTurnId"),
|
||||
next.get("sourceTurnId"))
|
||||
|| !ManagedExtensionRecords.same(previous.get("contentRef"),
|
||||
next.get("contentRef"))) {
|
||||
return false;
|
||||
}
|
||||
JsonNode beforeSegments = previous.get("segments");
|
||||
JsonNode afterSegments = next.get("segments");
|
||||
if (beforeSegments.size() != afterSegments.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < beforeSegments.size(); index++) {
|
||||
JsonNode before = beforeSegments.get(index);
|
||||
JsonNode after = afterSegments.get(index);
|
||||
if (!ManagedExtensionRecords.same(before.get("segmentId"),
|
||||
after.get("segmentId"))
|
||||
|| !ManagedExtensionRecords.same(before.get("ordinal"),
|
||||
after.get("ordinal"))
|
||||
|| !ManagedExtensionRecords.same(before.get("contentRef"),
|
||||
after.get("contentRef"))
|
||||
|| (!before.get("receipt").isNull()
|
||||
&& !ManagedExtensionRecords.same(
|
||||
before.get("receipt"),
|
||||
after.get("receipt")))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (previous.get("cancelRequested").booleanValue()
|
||||
&& !next.get("cancelRequested").booleanValue()) {
|
||||
return false;
|
||||
}
|
||||
if (!ManagedExtensionRecords.isRunSuccessor(previous.get("run"),
|
||||
next.get("run"))) {
|
||||
return false;
|
||||
}
|
||||
// Leaving unknown takes proof: the partial revision must
|
||||
// settle a segment the unknown one did not (decision 5).
|
||||
if ("unknown".equals(previous.at("/run/delivery/state").textValue())
|
||||
&& "partial".equals(next.at("/run/delivery/state").textValue())
|
||||
&& settled(next) <= settled(previous)) {
|
||||
return false;
|
||||
}
|
||||
if (ManagedExtensionRecords.TERMINAL.contains(
|
||||
previous.get("run").get("state").textValue())) {
|
||||
return ManagedExtensionRecords.same(previous, next);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private static int settled(JsonNode record) {
|
||||
int count = 0;
|
||||
for (JsonNode segment : record.get("segments")) {
|
||||
if (!segment.get("receipt").isNull()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static String nullableId(JsonNode node, String label) {
|
||||
return node.isNull() ? null : ManagedExtensionRecords.id(node, label);
|
||||
}
|
||||
|
||||
private static boolean accepts(BooleanSupplier check) {
|
||||
try {
|
||||
return check.getAsBoolean();
|
||||
} catch (InvalidRecordException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new InvalidRecordException(message + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import static java.util.Map.entry;
|
||||
|
||||
import com.alibaba.qwen.code.runtimebroker.ToolExecutionRecord;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
|
@ -21,53 +23,63 @@ import java.util.function.Predicate;
|
|||
* module managed-extension-projection.ts there replays the same cases.
|
||||
*/
|
||||
public final class ManagedExtensionProjection {
|
||||
/** The record bodies defined so far; a slice adds its body here. */
|
||||
public static final Map<String, Body> RECORD_BODIES = Map.of(
|
||||
"monitor_run", new Body(record -> "monitor",
|
||||
/** The record bodies defined so far; a slice adds its body here. ofEntries, since Map.of caps ten pairs. */
|
||||
public static final Map<String, Body> RECORD_BODIES = Map.ofEntries(
|
||||
entry("monitor_run", new Body(record -> "monitor",
|
||||
ManagedExtensionRecords::requireMonitorRun,
|
||||
body -> body.get("monitorId").textValue(),
|
||||
ManagedExtensionRecords::isMonitorRunStart,
|
||||
ManagedExtensionRecords::isMonitorRunSuccessor),
|
||||
"mcp_configuration", new Body(record -> null,
|
||||
ManagedExtensionRecords::isMonitorRunSuccessor)),
|
||||
entry("mcp_configuration", new Body(record -> null,
|
||||
ManagedMcpRecords::requireConfiguration,
|
||||
body -> body.get("configurationId").textValue(),
|
||||
ManagedMcpRecords::isConfigurationStart,
|
||||
ManagedMcpRecords::isConfigurationSuccessor),
|
||||
"mcp_operation", new Body(record -> null,
|
||||
ManagedMcpRecords::isConfigurationSuccessor)),
|
||||
entry("mcp_operation", new Body(record -> null,
|
||||
ManagedMcpRecords::requireOperation,
|
||||
body -> body.get("operationId").textValue(),
|
||||
ManagedMcpRecords::isOperationStart,
|
||||
ManagedMcpRecords::isOperationSuccessor),
|
||||
"hook_registration", new Body(record -> null,
|
||||
ManagedMcpRecords::isOperationSuccessor)),
|
||||
entry("hook_registration", new Body(record -> null,
|
||||
ManagedHookRecords::requireRegistration,
|
||||
body -> body.get("registrationId").textValue(),
|
||||
ManagedHookRecords::isRegistrationStart,
|
||||
ManagedHookRecords::isRegistrationSuccessor),
|
||||
"hook_execution", new Body(record -> null,
|
||||
ManagedHookRecords::isRegistrationSuccessor)),
|
||||
entry("hook_execution", new Body(record -> null,
|
||||
ManagedHookRecords::requireExecution,
|
||||
body -> body.get("hookExecutionId").textValue(),
|
||||
ManagedHookRecords::isExecutionStart,
|
||||
ManagedHookRecords::isExecutionSuccessor),
|
||||
"child_run", new Body(ManagedExtensionRecords::childRunTaskKind,
|
||||
ManagedHookRecords::isExecutionSuccessor)),
|
||||
entry("child_run", new Body(ManagedExtensionRecords::childRunTaskKind,
|
||||
ManagedExtensionRecords::requireChildRun,
|
||||
ManagedExtensionRecords::childRunRecordId,
|
||||
ManagedExtensionRecords::isChildRunStart,
|
||||
ManagedExtensionRecords::isChildRunSuccessor),
|
||||
"child_acceptance", new Body(record -> null,
|
||||
ManagedExtensionRecords::isChildRunSuccessor)),
|
||||
entry("channel_route", new Body(record -> null,
|
||||
ManagedChannelRecords::requireRoute,
|
||||
body -> body.get("routeId").textValue(),
|
||||
ManagedChannelRecords::isRouteStart,
|
||||
ManagedChannelRecords::isRouteSuccessor)),
|
||||
entry("channel_delivery", new Body(record -> null,
|
||||
ManagedChannelRecords::requireDelivery,
|
||||
body -> body.get("deliveryId").textValue(),
|
||||
ManagedChannelRecords::isDeliveryStart,
|
||||
ManagedChannelRecords::isDeliverySuccessor)),
|
||||
entry("child_acceptance", new Body(record -> null,
|
||||
ManagedExtensionRecords::requireChildAcceptance,
|
||||
body -> body.get("childRunId").textValue(),
|
||||
ManagedExtensionRecords::isChildAcceptanceStart,
|
||||
ManagedExtensionRecords::isChildAcceptanceSuccessor),
|
||||
"schedule", new Body(record -> null,
|
||||
ManagedExtensionRecords::isChildAcceptanceSuccessor)),
|
||||
entry("schedule", new Body(record -> null,
|
||||
ManagedExtensionRecords::requireScheduleRecord,
|
||||
body -> body.get("scheduleId").textValue(),
|
||||
ManagedExtensionRecords::isScheduleStart,
|
||||
ManagedExtensionRecords::isScheduleSuccessor),
|
||||
"automation_run", new Body(record -> "automation_run",
|
||||
ManagedExtensionRecords::isScheduleSuccessor)),
|
||||
entry("automation_run", new Body(record -> "automation_run",
|
||||
ManagedExtensionRecords::requireAutomationRunRecord,
|
||||
body -> body.get("automationRunId").textValue(),
|
||||
ManagedExtensionRecords::isAutomationRunStart,
|
||||
ManagedExtensionRecords::isAutomationRunSuccessor));
|
||||
ManagedExtensionRecords::isAutomationRunSuccessor)));
|
||||
public static final List<String> TASK_STATES = List.of("pending",
|
||||
"running", "waiting", "completed", "failed", "cancelled",
|
||||
"degraded", "recovery_blocked");
|
||||
|
|
|
|||
|
|
@ -518,6 +518,24 @@ public class ManagedExtensionRecordStore {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (domain.equals("channel_route")) {
|
||||
JsonNode ref = record.get("policyRef");
|
||||
requireReference(resources.apply(ref.get("resourceId").textValue()), ref);
|
||||
}
|
||||
if (domain.equals("channel_delivery")) {
|
||||
JsonNode ref = record.get("contentRef");
|
||||
requireReference(resources.apply(ref.get("resourceId").textValue()), ref);
|
||||
for (JsonNode segment : record.get("segments")) {
|
||||
JsonNode segmentRef = segment.get("contentRef");
|
||||
requireReference(resources.apply(segmentRef.get("resourceId").textValue()),
|
||||
segmentRef);
|
||||
JsonNode proofRef = segment.get("receipt").get("proofRef");
|
||||
if (proofRef != null && !proofRef.isNull()) {
|
||||
requireReference(resources.apply(proofRef.get("resourceId").textValue()),
|
||||
proofRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (domain.equals("hook_execution")) {
|
||||
StoredResource plan = resources.apply(record.get("planRef").get("resourceId").textValue());
|
||||
if (plan.kind().equals("managed-hook-plan")) {
|
||||
|
|
|
|||
|
|
@ -1483,9 +1483,12 @@ public final class ManagedExtensionRecords {
|
|||
|
||||
/**
|
||||
* Equality that compares numbers by value, so a record built in Java,
|
||||
* where 4 may be a long, matches the same record parsed from JSON.
|
||||
* where 4 may be a long, matches the same record parsed from JSON, and
|
||||
* both read decimal spellings canonically, like JSON.stringify does.
|
||||
* Package-wide: the sibling record classes share it rather than
|
||||
* comparing doubles, which splits -0.0 from 0.0.
|
||||
*/
|
||||
private static boolean same(JsonNode left, JsonNode right) {
|
||||
static boolean same(JsonNode left, JsonNode right) {
|
||||
return left.equals(SAME_VALUE, right);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionProjection;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords.InvalidRecordException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ManagedChannelRecordContractTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void validatesTheSharedRecordsAndStarts() throws IOException {
|
||||
JsonNode fixtures = fixtures();
|
||||
for (JsonNode fixture : fixtures.get("cases")) {
|
||||
String domain = fixture.get("domain").textValue();
|
||||
String id = fixture.get("id").textValue();
|
||||
var body = ManagedExtensionProjection.RECORD_BODIES.get(domain);
|
||||
assertNull(body.taskKindOf().apply(null));
|
||||
JsonNode record = merge(fixtures.get("templates").get(domain), fixture.get("patch"));
|
||||
if (fixture.get("valid").booleanValue()) {
|
||||
body.require().accept(record);
|
||||
} else {
|
||||
assertThrows(InvalidRecordException.class, () -> body.require().accept(record), id);
|
||||
}
|
||||
assertEquals(fixture.get("start").booleanValue(), body.isStart().test(record), id);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesTheSharedSuccessors() throws IOException {
|
||||
JsonNode fixtures = fixtures();
|
||||
for (JsonNode fixture : fixtures.get("successors")) {
|
||||
String domain = fixture.get("domain").textValue();
|
||||
JsonNode template = fixtures.get("templates").get(domain);
|
||||
assertEquals(fixture.get("valid").booleanValue(),
|
||||
ManagedExtensionProjection.RECORD_BODIES.get(domain).isSuccessor().test(
|
||||
merge(template, fixture.get("before")), merge(template, fixture.get("after"))),
|
||||
fixture.get("id").textValue());
|
||||
}
|
||||
}
|
||||
|
||||
static JsonNode fixtures() throws IOException {
|
||||
Path directory = Path.of("").toAbsolutePath();
|
||||
while (directory != null) {
|
||||
Path path = directory.resolve("packages/core/src/managed-runtime/contracts/managed-channel-record-v1.fixtures.json");
|
||||
if (Files.exists(path)) {
|
||||
return JSON.readTree(Files.readString(path));
|
||||
}
|
||||
directory = directory.getParent();
|
||||
}
|
||||
throw new IOException("Channel contract fixtures not found");
|
||||
}
|
||||
|
||||
static JsonNode merge(JsonNode base, JsonNode patch) {
|
||||
ObjectNode value = base == null || !base.isObject() ? JSON.createObjectNode() : base.deepCopy();
|
||||
patch.fields().forEachRemaining(entry -> value.set(entry.getKey(), entry.getValue().isObject()
|
||||
? merge(value.get(entry.getKey()), entry.getValue()) : entry.getValue().deepCopy()));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -566,6 +566,116 @@ class ManagedExtensionRecordStoreTest {
|
|||
return body;
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifiesTheChannelResourceClosure() throws Exception {
|
||||
JsonNode templates = ManagedChannelRecordContractTest.fixtures()
|
||||
.required("templates");
|
||||
CommitResource policy = channelResource("policy-1", "channel-policy",
|
||||
"{\"allowed\":[\"sender-1\"]}");
|
||||
CommitResource result = channelResource("result-1", "managed-tool-result",
|
||||
"{\"text\":\"ok\"}");
|
||||
CommitResource seg1 = channelResource("seg-1-data",
|
||||
"channel-delivery-segment", "part one");
|
||||
CommitResource seg2 = channelResource("seg-2-data",
|
||||
"channel-delivery-segment", "part two");
|
||||
CommitResource proof = channelResource("proof-1",
|
||||
"channel-delivery-receipt", "{\"250\":\"ok\"}");
|
||||
// Positive: both shared templates commit with their closures, and a
|
||||
// revision closing a proofed receipt commits with the proof. The
|
||||
// corpus templates carry placeholder reference digests, so pin them
|
||||
// onto the resources this transaction actually provides.
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
ExtensionRecordJournal journal = journal(sessionId);
|
||||
var route = journal.requestDomain("route-1", "channel_route",
|
||||
channelRoute(templates, policy), List.of(policy), 1_000);
|
||||
journal.commit(route);
|
||||
journal.committed(route);
|
||||
var delivery = journal.requestDomain("delivery-1", "channel_delivery",
|
||||
channelDelivery(templates, result, seg1, seg2),
|
||||
List.of(result, seg1, seg2), 2_000);
|
||||
journal.commit(delivery);
|
||||
journal.committed(delivery);
|
||||
ObjectNode sending = channelDelivery(templates, result, seg1, seg2);
|
||||
ObjectNode run = (ObjectNode) sending.required("run");
|
||||
run.put("state", "running");
|
||||
ObjectNode line = (ObjectNode) run.required("delivery");
|
||||
line.put("state", "sending");
|
||||
ObjectNode firstSegment = (ObjectNode) sending.required("segments")
|
||||
.get(0);
|
||||
ObjectNode receipt = JsonNodeFactory.instance.objectNode();
|
||||
receipt.put("providerMessageId", "provider-msg-1");
|
||||
receipt.put("acceptedAt", 1_750_000_000_000L);
|
||||
receipt.set("proofRef", hookRef(proof));
|
||||
firstSegment.set("receipt", receipt);
|
||||
var proofed = journal.requestDomain("delivery-2", "channel_delivery",
|
||||
sending, List.of(proof), 3_000);
|
||||
journal.commit(proofed);
|
||||
journal.committed(proofed);
|
||||
// Negative: every missing resource kind refuses on its own.
|
||||
String routeWs = UUID.randomUUID().toString();
|
||||
ExtensionRecordJournal routeless = journal(routeWs);
|
||||
assertRefused("channel_route names no policy resource", routeWs,
|
||||
ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, null,
|
||||
() -> routeless.commit(routeless.requestDomain("route-1",
|
||||
"channel_route", channelRoute(templates, policy),
|
||||
List.of(), 1_000)));
|
||||
String deliveryWs = UUID.randomUUID().toString();
|
||||
ExtensionRecordJournal noResult = journal(deliveryWs);
|
||||
assertRefused("channel_delivery names no result resource", deliveryWs,
|
||||
ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, null,
|
||||
() -> noResult.commit(noResult.requestDomain("delivery-1",
|
||||
"channel_delivery",
|
||||
channelDelivery(templates, result, seg1, seg2),
|
||||
List.of(seg1, seg2), 1_000)));
|
||||
assertRefused("channel_delivery names no segment resource",
|
||||
deliveryWs,
|
||||
ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, null,
|
||||
() -> noResult.commit(noResult.requestDomain("delivery-1",
|
||||
"channel_delivery",
|
||||
channelDelivery(templates, result, seg1, seg2),
|
||||
List.of(result, seg1), 1_000)));
|
||||
String proofWs = UUID.randomUUID().toString();
|
||||
ExtensionRecordJournal noProof = journal(proofWs);
|
||||
var base = noProof.requestDomain("delivery-1", "channel_delivery",
|
||||
channelDelivery(templates, result, seg1, seg2),
|
||||
List.of(result, seg1, seg2), 1_000);
|
||||
noProof.commit(base);
|
||||
noProof.committed(base);
|
||||
assertRefused("channel_delivery names no proof resource", proofWs,
|
||||
ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, null,
|
||||
() -> noProof.commit(noProof.requestDomain("delivery-2",
|
||||
"channel_delivery", sending.deepCopy(), List.of(),
|
||||
2_000)));
|
||||
}
|
||||
|
||||
private static CommitResource channelResource(String resourceId,
|
||||
String kind, String text) {
|
||||
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||
return new CommitResource(resourceId, kind, 1, bytes.length,
|
||||
ExtensionRecordJournal.sha256(bytes),
|
||||
Base64.getEncoder().encodeToString(bytes));
|
||||
}
|
||||
|
||||
private static ObjectNode channelRoute(JsonNode templates,
|
||||
CommitResource policy) {
|
||||
ObjectNode route = (ObjectNode) templates.required("channel_route")
|
||||
.deepCopy();
|
||||
route.set("policyRef", hookRef(policy));
|
||||
return route;
|
||||
}
|
||||
|
||||
private static ObjectNode channelDelivery(JsonNode templates,
|
||||
CommitResource result, CommitResource seg1, CommitResource seg2) {
|
||||
ObjectNode delivery = (ObjectNode) templates
|
||||
.required("channel_delivery").deepCopy();
|
||||
delivery.set("contentRef", hookRef(result));
|
||||
// segments are an ArrayNode; each element is mutable in place.
|
||||
((ObjectNode) delivery.required("segments").get(0)).set("contentRef",
|
||||
hookRef(seg1));
|
||||
((ObjectNode) delivery.required("segments").get(1)).set("contentRef",
|
||||
hookRef(seg2));
|
||||
return delivery;
|
||||
}
|
||||
private static ObjectNode childRun(String state, String execution,
|
||||
String runtimeBinding, CommitResource argsResource) {
|
||||
ObjectNode body = JsonNodeFactory.instance.objectNode();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue