22 KiB
SaaS ↔ Dispatcher 对接契约(MQ-only 设计与实现差异)
1. 适用范围与事实等级
用户已确认:RabbitMQ 是 SaaS 与 Dispatcher 的唯一交互通道,双方之间禁止任何 HTTP 请求或回调。每个 Dispatcher 都有独立、全局唯一的 ID,并通过各自独立的专用 Topic 接收事件。 本规则覆盖执行、控制、查询、补传、AI 配置与授权、录音上传会话及完成验证,不保留 HTTP 特例或回退。
OSS 补充确认:OSS 相关配置存于 Dispatcher 配置文件;Agent 经 Unary 向 Dispatcher 领取临时上传 TOKEN 后直传 OSS,不持有长期凭据。SaaS 不再下发 OSS 配置或上传 TOKEN。此次只调整配置/TOKEN 来源,上传完成后的 SaaS 独立校验和 MQ verified 职责保持不变。
本文区分三种事实:
| 层级 | 本次状态 | 使用边界 |
|---|---|---|
| 已确认设计 | MQ-only、Dispatcher 唯一身份、独立 Topic | 后续设计与实现必须遵守 |
| 待冻结的消息契约 | 身份分配/持久化、Topic 命名、消息类型、关联字段、错误与超时规则 | 见 §2、§5、§6;不能据中文语义自行拼 JSON 或给旧 Schema 加字段 |
| 现有实现/旧包 | 下列旧字段、路由及代码事实 | 仅用于识别差异,不代表新设计已实现或通过验收 |
当前固定包为 contracts/upstream/2026-09-19-p1-v1/(contracts.SourceCommit = 2026-09-19-p1-v1)。其中的 HTTP OpenAPI 与仅按租户路由的 MQ 拓扑不再是目标方案。旧包及其哈希保持不变;W01 须发布新版本、严格 Schema、拓扑及正反例,不能手改旧包、生成字段索引或通过放宽 additionalProperties 绕过冻结。
本轮只纠正文档,不修改代码、Proto 或 Schema,也不宣称新 MQ 闭环已通过。现有实现事实沿用此前核验记录,受影响部分必须按新基线重新验证。
2. 通信拓扑、Dispatcher 身份与交付语义
2.1 唯一通道与边界
| 交互 | 唯一允许的路径 | 语义 |
|---|---|---|
| SaaS 下发执行、控制、查询、补传 | SaaS → RabbitMQ → 指定 Dispatcher 专用 Topic/队列 | 持久受理不等于执行完成;响应仍经 MQ |
| Dispatcher 回传结果、查询响应和业务事件 | Dispatcher → RabbitMQ → SaaS 专用订阅 | 能识别来源 Dispatcher、租户、原请求及业务对象 |
| Dispatcher 获取 AI 配置/授权 | Dispatcher → RabbitMQ → SaaS;SaaS → RabbitMQ → 原 Dispatcher 专用订阅 | 固定租户和不可变版本,响应不能被其它 Dispatcher 消费 |
| 业务上传会话、complete/verified | Dispatcher ↔ RabbitMQ ↔ SaaS | 只传业务会话/对象元信息与验证结果;不下发 OSS 配置或 TOKEN,不传录音字节 |
| 临时上传 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在本文中是逻辑身份名称,尚不是旧 MQ Schema 或 Proto 已有字段。每个 Dispatcher 的 ID 必须独立、全局不重复;不能拿租户 ID、Agent ID、Cell ID、地址或启动代次代替。- Dispatcher 身份与
dispatcher_epoch分开:前者识别 Dispatcher,后者用于一次运行所有权/会话的 fencing。正常重启、恢复时如何保持身份及拒绝重复身份,须在 W01 冻结并由 W05 验证,不因 epoch 改变就丢弃原消息、执行或资产归属。 - 每个 Dispatcher 有独立的接收 Topic 及对应队列/绑定;多个 Dispatcher 不能共用一条接收队列竞争消费指定目标的消息,也不能全部订阅同一广播 Topic 后仅靠正文过滤。
- RabbitMQ 的 Topic 订阅由 exchange、routing key、queue 和 binding 表达;具体名称、类型、绑定格式及 ID 在信封/属性中的位置随新版本冻结。本轮不另造一套可直接部署的命名格式。
- 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 和实际目标消费证据。
- 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.#。这些名称记录旧包事实,不构成新拓扑批准。
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.ready |
旧 CompleteUpload 本地对象验证后同事务写完成状态/outbox;不等于 SaaS MQ verified |
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 |
新设计中 recording.ready 只能在 D 收到并持久校验 SaaS 的 MQ verified 结果及 oss_id 后发布;PUT 2xx、ETag、本地路径或 D 单独 HEAD 成功都不替代该结果。
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 待冻结内容
以下只定义已确认业务语义,消息类型名、信封字段、响应/错误枚举尚未发布。旧 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 重新发布来重新执行。旧 HTTP source-command replay 当前重发原命令的行为不满足目标整体结果补传,须列入 W12 修正;不能因改为 MQ 就保留这一错误语义。
5.5 已废弃 HTTP 入口的处理
internal/control/http.go 当前存在控制、命令查询/补传 handler,通话查询/补传路由固定返回 404。这些只是旧实现事实,不再是可接入或待扩展的 SaaS 接口。W05/W12 应移除这些 SaaS HTTP 业务入口及相关部署说明,不保留兼容层、并行双通道或 HTTP 兜底。本轮未改代码,不能宣称入口已移除。
6. Dispatcher → SaaS:AI 配置与上传业务协调经 MQ
6.1 Dispatcher 配置、临时 TOKEN 与 complete/verified
- OSS 配置唯一来源是 D 的配置文件,包括所需服务地址、bucket、对象路径规则及签发授权所需的受控凭据配置/引用。配置文件格式及具体字段沿现有能力核验后冻结,本轮不新增猜测的配置键,也不在文档/样例/源码/日志中写实际密钥或完整 TOKEN。配置缺失或无效须明确失败,不改向 SaaS 取配置,不用 Agent 本地配置兜底。
- Agent 经 R12 向 D 领取临时上传 TOKEN。D 校验并持久关联原租户/执行/资产,依据自身配置复用官方 SDK 提供仅本对象可用、有有效期/方法/大小约束的 TOKEN 及必要上传目标信息,经 Unary 返回 Agent。Agent 不取得 D 的长期凭据或完整配置文件。精确 TOKEN 形态及与现有
UploadGrant的映射待核验,不假定某个 SDK/Proto 已满足全部约束。 - 业务上传会话与 TOKEN 签发分开:既有 SaaS 业务会话/资产登记语义仍经 MQ,响应回原 D 专用 Topic;但该响应不再承担 OSS 配置或 TOKEN 的来源。
upload_id、对象引用及会话/资产关联按新版合同冻结,不因 TOKEN 过期另造资产,也不新加一套未批准的登记协议。 - Agent 直接 PUT 文件到 OSS,经 R13 只提交原资产/会话、对象引用、大小和 SHA-256 等完成元信息。D 经 MQ 提交 complete,SaaS 仍独立验证对象后经 MQ 返回 verified、
oss_id或明确失败;D 持久校验 verified 后同事务写资产状态和recording.readyoutbox。 - TOKEN 过期/失效由 Agent 显式向 D 重新申请,D 仍按自身配置提供,不向 SaaS 申请 TOKEN,不自动续期/重试。MQ 响应丢失/重复沿原资产、请求和
upload_id恢复;未获 SaaS verified 保留待完成状态和文件,不提前 ready。
复用的业务数据包括 recording_id、call_id、content_type、size_bytes、SHA-256、声道/采样率/时长。D→A 的临时授权含原会话、TOKEN/上传目标、方法、必要 headers、约束和有效期;D↔SaaS 的 MQ 只承担业务元信息/会话及最终验证,不传 D 的配置文件、长期凭据或临时 TOKEN。SaaS 为独立校验取得必要对象定位及读取能力的既有业务要求仍须满足,精确合同在 W01 冻结,不能假定“D 持有配置”即代表 SaaS 已能验证。
业务会话/complete 的 MQ 异步结果与 R12/R13 Unary 的衔接须由 W02/W11 冻结 pending、超时、原操作重取及最终结果;TOKEN 本身来自 D,不等待 SaaS 下发配置/TOKEN。不能无限阻塞 RPC,也不能收到 broker confirm 就返回已完成。旧 saas.openapi.yaml 仅作语义对照,不是新 MQ Schema;本轮不修改 Proto/配置格式或新增字段。
当前 D 用 internal/oss client 签发 grant 的职责与新确认方向一致,不应再把 D 签发能力列为待删除或“仅故障回退”;但配置文件读取、TOKEN 约束及完整接线仍须核验。当前 D 本地验证对象即发 ready 的旧行为仍不能替代 SaaS MQ verified。旧 SaaS HTTP upload-session/complete 方案继续废弃,不开发 HTTP client。
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。当前 AISnapshotRaw/AIAuthorizationRaw 启动注入和 Agent 本地校验只证明旧路径;MQ 配置/授权、关联与持久恢复仍待 W01/W07 实现验证。消息细节不能由旧 OpenAPI 自动推定。
7. 待完成门禁与禁止误读
| 门禁 | 完成证据 | 当前状态 |
|---|---|---|
| W01 身份/Topic/消息冻结 | 新版本/来源/哈希、完整消息 Schema、路由、关联/错误/期限及正反例 | 待冻结 |
| W05/W12 MQ 控制面 | 全部交互持久接收/响应、移除旧 HTTP、补传语义正确 | 待实现/验证 |
| W07 MQ AI | 不可变配置/授权、迟到/撤销/重复与缓存隔离 | 待实现/验证 |
| W02/W11 上传授权与业务协调 | D 配置文件→临时 TOKEN→A 直传;R12/R13 与 MQ 业务结果有界衔接、SaaS verified 后才 ready | 待核验/实现/验证 |
| 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 修订与状态。
- 时间泳道图:目标流程,不冒充当前实现。
- Dispatcher ↔ Agent 契约:现有 Proto/handler 事实与上传协调待改项。
- 旧实现事实:
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、internal/control/http.go。 - 旧固定包:
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 新契约已发布。