21 KiB
SaaS ↔ Dispatcher 对接契约(MQ-only 设计与实现差异)
1. 适用范围与事实等级
用户已确认:RabbitMQ 是 SaaS 与 Dispatcher 的唯一交互通道,双方之间禁止任何 HTTP 请求或回调。每个 Dispatcher 都有独立、全局唯一的 ID,并通过各自独立的专用 Topic 接收事件。 本规则覆盖执行、控制、查询、补传、AI 配置与授权、recording.uploaded上传事实通知,不保留HTTP特例或回退;上传不等待SaaS会话或校验回复。
OSS 补充确认:OSS 相关配置存于 Dispatcher 配置文件;Agent 经 Unary 向 Dispatcher 领取临时上传 TOKEN 后直传 OSS,不持有长期凭据。SaaS 不再下发 OSS 配置或上传 TOKEN。用户随后修订目标:本项目只保证上传事实可靠进入指定持久MQ队列,不关心SaaS后续处理;不等待上传会话、verified或OSS ID,不新增VERIFYING。
本文区分三种事实:
| 层级 | 本次状态 | 使用边界 |
|---|---|---|
| 已确认设计 | MQ-only、Dispatcher 唯一身份、独立 Topic | 后续设计与实现必须遵守 |
| 新版项目内契约 | 2026-09-21-p1-v2身份/Topic/消息/配置/正反例及哈希,AI JCS修订后的v3包 |
以mq-only-v2冻结方案及机读包为准;本地运行往返已有证据,外部SaaS签收仍不在本轮 |
| 现有实现/旧包 | 下列旧字段、路由及代码事实 | 仅用于识别差异,不代表新设计已实现或通过验收 |
当前固定包为 contracts/upstream/2026-09-19-p1-v1/(contracts.SourceCommit = 2026-09-19-p1-v1)。其中的 HTTP OpenAPI 与仅按租户路由的 MQ 拓扑不再是目标方案。旧包及其哈希保持不变;W01 须发布新版本、严格 Schema、拓扑及正反例,不能手改旧包、生成字段索引或通过放宽 additionalProperties 绕过冻结。
当前已完成本地实现阶段;v2机读包、AI摘要修订后的v3包、离线正反例、路由规则及RabbitMQ运行往返均有证据。下文§3–§4的v1字段/代码描述仅为迁移差异,不能作为v2/v3接入要求;运行证据不等于外部SaaS签收。
2. 通信拓扑、Dispatcher 身份与交付语义
2.1 唯一通道与边界
| 交互 | 唯一允许的路径 | 语义 |
|---|---|---|
| SaaS 下发执行、控制、查询、补传 | SaaS → RabbitMQ → 指定 Dispatcher 专用 Topic/队列 | 持久受理不等于执行完成;响应仍经 MQ |
| Dispatcher 回传结果、查询响应和业务事件 | Dispatcher → RabbitMQ → SaaS 专用订阅 | 能识别来源 Dispatcher、租户、原请求及业务对象 |
| Dispatcher 获取 AI 配置/授权 | Dispatcher → RabbitMQ → SaaS;SaaS → RabbitMQ → 原 Dispatcher 专用订阅 | 固定租户和不可变版本,响应不能被其它 Dispatcher 消费 |
| 上传完成通知 | Dispatcher → RabbitMQ指定持久队列 | recording.uploaded仅含事实元信息;入队即完成本项目交付,不等待SaaS消费/会话/verified/OSS ID |
| 临时上传 TOKEN 领取/显式重新申请 | Agent ↔ Unary ↔ Dispatcher | D 依据自身配置文件提供受限 TOKEN/上传目标;不向 SaaS 申请 TOKEN |
| Dispatcher ↔ Agent | 既有 Unary gRPC | 不改为内部 MQ,也不让 Agent 直连 SaaS |
| Agent → OSS | 受限目标上的直接 PUT | 保留 HTTP(S) 对象上传;禁止的是 SaaS↔Dispatcher HTTP,不是 OSS/ARI/供应商协议或 gRPC 的 HTTP/2 |
2.2 全局唯一身份与独立 Topic
dispatcher_id是当前v2/v3运行信封和路由中的独立全局身份;每个 Dispatcher 的 ID 必须独立、全局不重复,不能拿租户 ID、Agent ID、Cell ID、地址或启动代次代替。- Dispatcher 身份与
dispatcher_epoch分开:前者识别 Dispatcher,后者用于一次运行所有权/会话的 fencing。身份持久绑定、重复身份占用、重启恢复及失效会话拒绝已有本地测试;不因 epoch 改变就丢弃原消息、执行或资产归属。 - 每个 Dispatcher 有独立的接收 Topic 及对应队列/绑定;多个 Dispatcher 不能共用一条接收队列竞争消费指定目标的消息,也不能全部订阅同一广播 Topic 后仅靠正文过滤。
- RabbitMQ 的 Topic 订阅由 exchange、routing key、queue 和 binding 表达;当前固定为
agent-call.dispatchers.v2、agent-call.saas.v2、agent-call.dead-letter.v2及每个 Dispatcher/租户的独立队列和精确路由键,字段以v2/v3机读契约包为准。 - SaaS 发给 D1 的命令、配置、授权和上传结果,只能进入 D1 的专用接收路径;D2 的路径与之独立。D1 发出的响应/事件须能回溯 D1 与原请求。SaaS 订阅布局亦由同一版契约定义,不假定现有共享结果队列已满足新约束。
- 独立 Dispatcher 路由不替代租户隔离:保留租户独立队列、有界窗口、原值
tenant_key和复合幂等语义;新拓扑必须同时区分 Dispatcher 与租户,不能退化为 Dispatcher 内所有租户共享无界队列。 tenant_key不清洗、编码或截断。旧布局的 224 UTF-8 字节预算不能在加上 Dispatcher 身份后直接照搬;W01 须校验完整 routing key/queue 名长度及分隔符、通配符边界,超限拒绝发布并保留源任务,不改变既有租户标识。
P1 仍只运行一个单活 Dispatcher。现在必须在合同及本地路由测试中区分两个 Dispatcher 身份;这不授权多节点上线、多 Dispatcher 共享配额、自动选主、HA 或自动迁移任务。未知执行不得因目标离线而改投另一个 Dispatcher。
2.3 请求、响应、持久化和恢复
- 所有请求和响应都走 MQ;异步响应必须关联原请求、目标/来源 Dispatcher、原租户及业务对象。精确键名、关联方式、消息枚举、错误与期限在 W01 冻结;
trace_id不能代替业务幂等身份。 - 发送意图/业务变更与 outbox 同事务;接收方持久 inbox 和处理状态后才 ACK。相同业务请求的重投返回原决定,同身份异内容冲突,不能生成第二次拨号或上传资产。
- publisher confirm、消费者ACK、业务accepted和控制applied各自独立;上传只验证可靠入队,不增加SaaS verified条件。confirm 只说明 broker 接收,不等于对端已应用;接收 ACK 不能代替业务响应。
- 响应重复、乱序、迟到、丢失和重启后恢复必须按原关联处理;响应等待有界,不跨网络持有 SQLite 写事务。超时表示未获确定结果,不等于业务失败,不允许 HTTP 查询兜底、换 ID 重拨或静默换 Dispatcher。
- 队列满、无绑定/不可路由、broker 断连必须可见并保留原消息。不能通过 confirm 单独认定路由成功;须覆盖 mandatory/return 和指定持久队列接收证据;上传无需SaaS消费者回复。
- MQ 往返响应不意味着新增一套任意 application receipt 协议。已有控制等业务结果保留;上传会话/verified往返已移出本项目;需要补齐的响应消息必须进入版本化契约,不能借现有八类业务事件自由透传。
3. RabbitMQ 执行命令:旧基线与待改项
3.1 旧拓扑(仅作实现差异记录,禁止用于新接入)
| 元素 | 旧值 | 新设计差异 |
|---|---|---|
| command exchange | agent-call.commands.v1,durable direct |
须按 §2 冻结面向指定 Dispatcher 的 Topic 拓扑 |
| tenant queue | agent-call.executor.{tenant_key}.v1 |
没有 Dispatcher 身份,不能让多个 D 共用 |
| routing key | agent-call.tenant.{tenant_key}.call.execute |
仅区分租户/操作,不能唯一指定 Dispatcher |
| event exchange | agent-call.events.v1,durable topic |
新发布路径须可识别来源 Dispatcher |
| dead-letter exchange | agent-call.dead-letter.v1,durable topic |
恢复必须保留原 Dispatcher、租户和消息身份 |
| 默认 prefetch | 1 |
保持有界消费;不是多 Dispatcher 隔离证明 |
旧实现按消费租户声明 command queue 和 .dlq.v1,SaaS 结果队列基线为 agent-call.saas.events.v1、binding agent-call.#。这些名称仅记录旧包事实,不构成新拓扑批准;当前运行使用v2/v3精确 Dispatcher/租户路由。
3.2 旧 call.execute 外壳
以下是旧 Schema 的精确字段记录,additionalProperties: false;新 Dispatcher 路由与关联尚未进入该外壳,不能直接追加字段并宣称兼容。
| 字段 | 类型 | 必填/约束 |
|---|---|---|
schema_version |
string | 旧版固定 1.0 |
command_type |
string | 固定 call.execute |
command_id |
string | 1–128 字节;不可含空白、/、\\ |
tenant_id |
string | 同上 |
tenant_key |
string | 非空有效 UTF-8;旧实现额外限制 224 字节 |
trace_id |
string | 同 id 约束 |
issued_at |
RFC3339 时间 | 必填 |
not_after |
RFC3339 时间 | 必填;不能因重投延期 |
payload |
object | 符合 executePayload |
3.3 旧 payload 数据结构
| 字段 | 类型 | 约束 |
|---|---|---|
execution_id |
string | 必填 ID |
task_id |
string | 必填 ID |
task_item_id |
string | 必填 ID |
task_revision |
integer | >= 1 |
callee |
string | 1–256 字符;保留原始被叫号码 |
route_policy_id |
string | 必填 ID |
caller_profile_id |
string | 必填 ID |
agent_version_id |
string | 必填 ID |
variables |
object | 必填;旧 Schema 允许附加属性,业务白名单仍受源约束 |
ring_timeout_ms |
integer | >= 1 |
max_call_duration_ms |
integer | >= 1 |
对应 internal/contract.ExecutePayload;payload 以 json.RawMessage 保留原始 JSON。改传输不授权改变业务号码、版本、摘要或新增 MQ mode 字段。
3.4 旧接收事实与新验收要求
旧 ConsumeTenant 校验租户、声明队列,以 prefetch=1 消费;AcceptCommand/Store.IngestCommand 校验源 Schema、routing key、not_after 和原始 body SHA-256。当前以 command_id 查 inbox,同 ID 同 body 为重复、异 body 为冲突;新命令同事务写 inbox、task、command.result(accepted) outbox。成功后 ACK;永久错误 Reject(false),其余错误 Nack(requeue=true)。
旧初始结果 payload:
{
"command_id": "<command_id>",
"command_type": "call.execute",
"execution_id": "<execution_id>",
"status": "accepted",
"reason_code": "accepted",
"requested_task_revision": 1
}
新验收须补充 §2 的 Dispatcher 定向/来源校验、所有 MQ 交互的关联和持久恢复,以及租户复合幂等要求。不能把当前仅按 command_id 查重的事实写成这些要求已满足。
4. RabbitMQ 业务事件:旧字段语义与新路由要求
4.1 旧通用外壳
旧 routing key 为 agent-call.{event_type};新来源 Dispatcher 的表达待 W01 冻结。以下字段在旧版全部必填:
{
"schema_version": "1.0",
"event_id": "<id>",
"event_type": "<event_type>",
"tenant_id": "<id>",
"tenant_key": "<原值>",
"trace_id": "<id>",
"occurred_at": "2026-09-19T00:00:00Z",
"aggregate_type": "<aggregate>",
"aggregate_id": "<id>",
"aggregate_version": 1,
"payload": {}
}
源枚举为 command.result、call.status、transcript.updated、call.finished、recording.ready、recording.failed、transcript.failed、contact.opt_out。它们不自动覆盖新增的配置/查询/上传响应消息。
事件同时通过 mq.schema.json 和 event-payloads.schema.json;EventBuilder 拒绝未知字段。旧实现由 SQLite 按 aggregate_type + aggregate_id 递增版本,Agent 不能指定版本;新基线仍须验证租户/Dispatcher 归属,不将旧实现等同完整隔离。
4.2 当前代码实际生成的事件
| 事件 | 当前代码行为 |
|---|---|
command.result |
命令接收及 EXECUTION_ACCEPTED fact 生成 |
call.status / call.finished |
对应 Agent fact 经 Dispatcher 校验后生成 |
transcript.updated |
实时文字;不得改名 call.transcript |
transcript.failed |
当前映射为 aggregate_type=transcript,但 Schema 要求 transcript_segment,该路径阻塞 |
contact.opt_out |
对应 Agent fact 经 Dispatcher 校验后生成 |
recording.uploaded |
当前上传事实;D同事务保存事实及outbox,可靠进入指定持久队列后完成本项目交付,不代表SaaS已处理 |
recording.failed |
Schema 已定义,当前无对应 FactKind/生成路径 |
RECORDING_PROGRESS 只保存 fact,不发布 MQ 事件。上述已知实现差异不因本次文档改写而消失。
4.3 旧专属 payload 关键字段
完整约束仍在固定包 event-payloads.schema.json;下表不是第二套 Schema。
| 事件 | 必填字段 |
|---|---|
command.result |
command_id, command_type, status, reason_code |
call.status |
call_id, execution_id, call_state, call_version, attempt_id, attempt_state |
transcript.updated |
call_id, turn_id, segment_id, role, revision, text, is_final, start_ms, end_ms, playback_state |
call.finished |
call_id, execution_id, call_version, outcome, started_at, ended_at, duration_ms, reason_code |
recording.ready |
call_id, recording_id, oss_id, format, channels, sample_rate_hz, duration_ms, size_bytes, checksum_sha256 |
recording.failed |
call_id, recording_id, stage, reason_code, retryable |
transcript.failed |
call_id, reason_code, retryable |
contact.opt_out |
call_id, task_id, task_item_id, requested_at |
v2已用recording.uploaded取代本项目的recording.ready;不得返回虚构OSS ID或等待SaaS verified。新字段与可靠入队边界见§6.1,旧表不作为v2校验依据。
4.4 Outbox 交付
旧 Dispatcher.FlushOutbox claim pending/retry 为 dispatching,发布 persistent JSON 并等 confirm;成功记 published,失败记 retry,重启把 dispatching 恢复为 retry。重复 fact 同 fact_id + content_sha256 不生成第二条事件,异摘要冲突。SaaS 按租户/事件身份幂等应用。
新设计将这一持久交付原则覆盖请求与响应,补齐不可路由、来源/目标、相关状态恢复验证。业务事件重投保留原身份、内容和域版本,broker confirm 不替代 SaaS 应用收讫。
5. SaaS → Dispatcher:控制、查询和补传全部经 MQ
5.1 待冻结内容
消息类型、信封和响应枚举以已确认的v2机读包及mq-only-v2合同为准。旧HTTP header、URL和状态码不是MQ合同,不能塞进旧call.execute或宽松metadata中。
5.2 控制任务
SaaS 将 pause/resume/stop 控制发到目标 Dispatcher 专用 Topic。保留原 command_id、租户/任务归属、expected_task_revision CAS、active_call_policy=drain|hangup 与原因语义。D 持久受理后经 MQ 回报 accepted;经 Agent 屏障/挂断事实确认后才能回报 applied。重复控制不重复增加 revision,冲突不能伪装成功,stopped 不可恢复。
5.3 查询命令与通话
请求和响应都经 MQ;查询固定原 command_id 或 call_id,返回可证明的命令、执行、控制、通话及独立资产状态。响应关联原查询和目标 Dispatcher;不存在、保留过期与暂时不可用分开表达,精确错误码待冻结。超时不走 HTTP 补查,也不证明原执行未发生。
5.4 整体补传
仅允许以 call_id 或 source_command_id 请求整体业务结果补传,不增加 task/execution 范围或局部筛选。固定受理截止点,重发原事件 ID/内容/版本,实时优先、分批有界;补传自身结果不能递归进入集合。
补传不是把 call.execute 重新发布来重新执行。当前MQ replay只重送已持久的原业务事实或原命令回执,重复、迟到和重启沿原消息身份恢复,不创建任务、不拨号、不新建资产。
5.5 已废弃 HTTP 入口的处理
旧internal/control/ HTTP业务实现及测试、Dispatcher HTTP启动路径、CLI参数和环境配置均已删除。执行、控制、查询、整体补传和AI配置/授权已有MQ本地往返、重复、错目标、迟到/重启恢复证据;不以HTTP删除代替外部SaaS验收。
6. Dispatcher → SaaS:AI 配置与上传业务协调经 MQ
6.1 Dispatcher配置、临时TOKEN与recording.uploaded
- OSS配置唯一来自D严格JSON配置文件;A经R12取得D用官方SDK提供的15分钟预签名PUT及headers,不持长期凭据,不向SaaS申请会话或授权。字段已在v2配置Schema冻结。
- A仅执行一次PUT并持久记录结果,R13只报告原upload_id、binding、资产、大小与摘要;D校验后将事实和固定event_id的outbox同事务保存。
- D发布persistent的
recording.uploaded到正确durable队列/绑定,启用mandatory并处理return。收到publisher confirm且确认未被退回,才能将本次交付记为完成;只写本地outbox或无队列的exchange不算成功。 - 通知payload固定为call_id、recording_id、upload_id、bucket、object_key、format、channels、sample_rate_hz、duration_ms、size_bytes、checksum_sha256,不含TOKEN、密钥、签名URL或SaaS OSS ID。
- broker故障、无绑定、confirm丢失和重启保留原消息身份并恢复通知,不重新PUT、新建资产或重新拨号。源文件与恢复记录在通知未确认前保留;TOKEN过期仅显式向D重申请。
- R13完成只表示本项目已可靠交付MQ,不表示SaaS已消费/处理。 不新增VERIFYING,不等待SaaS上传会话、verified或OSS ID,不发recording.ready。AI/控制等必要响应仍按各自合同处理。
D现有SDK签发能力保留;旧D直接HEAD校验并生成ready/OSS ID的完成路径已删除,不能保留为兼容层。当前handler已接入上述新入队完成边界;本地RabbitMQ无绑定、确认丢失、重启和原消息恢复已有证据,不等于真实OSS/SaaS联调。
6.2 AI 不可变配置与授权
D 根据 MQ 任务中的原 tenant_id/tenant_key + agent_version_id,通过 MQ 向 SaaS 获取不可变配置及有效授权,SaaS 通过原 D 专用 Topic 返回;授权/撤销等交互同样不能走 HTTP。
保留既有版本、immutable、content_sha256、config 及租户授权语义;源 Schema、不可变摘要、有效期、撤销、能力和供应商受控引用均校验后持久绑定到原执行,再交付 Agent。缓存按租户/版本隔离,在途/原排队任务不漂移,同版本异内容拒绝,0/false 与未提供保真;无有效授权时拒绝新准入,不用 latest、CLI/env 或 SDK 默认值兜底。
旧 ai-config.openapi.yaml 的 AI GET 已废弃为 D↔SaaS 接入方式,不再开发该 HTTP client。Dispatcher当前通过MQ请求/接收内嵌不可变配置和授权,按租户、版本、摘要、有效期、撤销和出口持久校验;实际本地RabbitMQ往返及SQLite重启证据见docs/evidence/mq-ai-local-roundtrip.md。Agent动态交付仍受现有Unary快照载体边界约束,不凭启动fixture宣称外部动态交付。
7. 当前门禁状态与禁止误读
| 门禁 | 完成证据 | 当前状态 |
|---|---|---|
| W01 身份/Topic/消息冻结 | v2项目内Schema、路由、正反例及哈希 | 已生成并离线验证;不是运行时或外部签收 |
| W05/W12 MQ 控制面 | 全部交互持久接收/响应、移除旧 HTTP、补传语义正确 | 本地完成;见mq-control-recovery.md、查询/补传证据;外部SaaS不在本轮 |
| W07 MQ AI | 不可变配置/授权、迟到/撤销/重复与缓存隔离 | 本地MQ请求/响应、重复、范围和SQLite恢复完成;见mq-ai-local-roundtrip.md |
| W02/W11 上传授权与通知入队 | D配置→临时TOKEN→A直传;recording.uploaded可靠入队后R13完成,无SaaS等待 | 本地完成;见20260921-mq-upload-progress.md;不宣称SaaS消费 |
| W13/W14 本地联合回归 | D1/D2 Topic 隔离 fixture、身份冲突、broker 故障、全流程无 SaaS↔D HTTP | 本地完成;全仓质量和部署诊断状态见最终证据,真实供应商/生产仍延期 |
路由 fixture 只验证不同 Dispatcher 互不抢收,不把多 Dispatcher 调度或真实 SaaS 联调引入本轮。旧包、旧 HTTP handler、单租户 broker 测试和本地 OSS 成功,均不代表以上门禁通过。
8. 依据与相关文档
- 计划与需求阅读索引:§1.2、§8.2 的 MQ-only 修订与状态。
- 时间泳道图:已按当前recording.uploaded边界更新;外部SaaS/真实OSS处理仍不在本地证据范围。
- Dispatcher ↔ Agent 契约:现有 Proto/handler 事实、15分钟授权和上传通知恢复边界。
- 旧实现事实:
internal/contract/contract.go、internal/mq/amqp.go、internal/tenant/routing.go、internal/dispatcher/consumer.go、internal/dispatcher/dispatcher.go、internal/store/store.go、internal/store/facts.go。旧HTTP源码仅可在历史基线中查阅,不是当前运行路径。 - 旧固定包:
contracts/upstream/2026-09-19-p1-v1/下mq.schema.json、event-payloads.schema.json、mq-topology.md、executor.openapi.yaml、saas.openapi.yaml、ai-config.openapi.yaml;保留原样,不代表 MQ-only 新契约已发布。