Files

21 KiB
Raw Permalink Blame History

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 后续设计与实现必须遵守
当前项目内契约 唯一 V1 包中的身份/Topic/消息/配置/正反例、哈希和 AI JCS 规则 mq-only V1冻结方案及机读包为准;本地运行往返已有证据,外部SaaS签收仍不在本轮
现有实现/旧包 下列旧字段、路由及代码事实 仅用于识别差异,不代表新设计已实现或通过验收

当前固定包为 contracts/upstream/v1/contracts.SourceCommit = v1)。其中不包含已归档的 HTTP OpenAPI,也不使用仅按租户路由的旧拓扑;当前 V1 包的 Schema、拓扑、正反例和哈希是唯一运行输入。不能在包外另造版本或通过放宽 additionalProperties 绕过冻结。

当前已完成本地实现阶段;唯一 V1 机读包、AI摘要、离线正反例、路由规则及RabbitMQ运行往返均有证据。下文旧字段/代码描述仅用于解释差异;运行证据不等于外部SaaS签收。

2. 通信拓扑、Dispatcher 身份与交付语义

2.1 唯一通道与边界

交互 唯一允许的路径 语义
SaaS 下发执行、控制、查询、补传 SaaS → RabbitMQ → 指定 Dispatcher 专用 Topic/队列 持久受理不等于执行完成;响应仍经 MQ
Dispatcher 回传结果、查询响应和业务事件 Dispatcher → RabbitMQ → SaaS 专用订阅 能识别来源 Dispatcher、租户、原请求及业务对象
Dispatcher 获取 AI 配置/授权 Dispatcher → RabbitMQ → SaaSSaaS → 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 是当前 V1 运行信封和路由中的独立全局身份;每个 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.v2agent-call.saas.v2agent-call.dead-letter.v2 及每个 Dispatcher/租户的独立队列和精确路由键,字段以唯一 V1 机读契约包为准。
  • 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 请求、响应、持久化和恢复

  1. 所有请求和响应都走 MQ;异步响应必须关联原请求、目标/来源 Dispatcher、原租户及业务对象。精确键名、关联方式、消息枚举、错误与期限在 W01 冻结;trace_id 不能代替业务幂等身份。
  2. 发送意图/业务变更与 outbox 同事务;接收方持久 inbox 和处理状态后才 ACK。相同业务请求的重投返回原决定,同身份异内容冲突,不能生成第二次拨号或上传资产。
  3. publisher confirm、消费者ACK、业务accepted和控制applied各自独立;上传只验证可靠入队,不增加SaaS verified条件。confirm 只说明 broker 接收,不等于对端已应用;接收 ACK 不能代替业务响应。
  4. 响应重复、乱序、迟到、丢失和重启后恢复必须按原关联处理;响应等待有界,不跨网络持有 SQLite 写事务。超时表示未获确定结果,不等于业务失败,不允许 HTTP 查询兜底、换 ID 重拨或静默换 Dispatcher。
  5. 队列满、无绑定/不可路由、broker 断连必须可见并保留原消息。不能通过 confirm 单独认定路由成功;须覆盖 mandatory/return 和指定持久队列接收证据;上传无需SaaS消费者回复。
  6. MQ 往返响应不意味着新增一套任意 application receipt 协议。已有控制等业务结果保留;上传会话/verified往返已移出本项目;需要补齐的响应消息必须进入版本化契约,不能借现有八类业务事件自由透传。

3. RabbitMQ 执行命令:旧基线与待改项

3.1 旧拓扑(仅作实现差异记录,禁止用于新接入)

元素 旧值 新设计差异
command exchange agent-call.commands.v1durable 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.v1durable topic 新发布路径须可识别来源 Dispatcher
dead-letter exchange agent-call.dead-letter.v1durable topic 恢复必须保留原 Dispatcher、租户和消息身份
默认 prefetch 1 保持有界消费;不是多 Dispatcher 隔离证明

旧实现按消费租户声明 command queue 和 .dlq.v1SaaS 结果队列基线为 agent-call.saas.events.v1、binding agent-call.#。这些名称仅记录旧实现事实,不构成当前拓扑;当前 V1 运行使用精确 Dispatcher/租户路由。

3.2 旧 call.execute 外壳

以下是旧 Schema 的精确字段记录,additionalProperties: false;新 Dispatcher 路由与关联尚未进入该外壳,不能直接追加字段并宣称兼容。

字段 类型 必填/约束
schema_version string 旧版固定 1.0
command_type string 固定 call.execute
command_id string 1128 字节;不可含空白、/\\
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 1256 字符;保留原始被叫号码
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.ExecutePayloadpayloadjson.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.resultcall.statustranscript.updatedcall.finishedrecording.uploadedrecording.failedtranscript.failedcontact.opt_out。它们不自动覆盖新增的配置/查询/上传响应消息。

事件同时通过 mq.schema.jsonevent-payloads.schema.jsonEventBuilder 拒绝未知字段。旧实现由 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.uploaded call_id, recording_id, upload_id, bucket, object_key, 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.uploaded只表示上传事实已可靠进入指定持久队列,不表示SaaS已消费或处理;字段与可靠入队边界见§6.1。

4.4 Outbox 交付

Dispatcher.FlushOutbox claim pending/retrydispatching,发布 persistent JSON 并等 confirm;成功记 published,失败记 retry,重启把 dispatching 恢复为 retry。重复 fact 同 fact_id + content_sha256 不生成第二条事件,异摘要冲突。SaaS 按租户/事件身份幂等应用。

新设计将这一持久交付原则覆盖请求与响应,补齐不可路由、来源/目标、相关状态恢复验证。业务事件重投保留原身份、内容和域版本,broker confirm 不替代 SaaS 应用收讫。

5. SaaS → Dispatcher:控制、查询和补传全部经 MQ

5.1 待冻结内容

消息类型、信封和响应枚举以唯一 V1 机读包及mq-only V1合同为准。旧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_idcall_id,返回可证明的命令、执行、控制、通话及独立资产状态。响应关联原查询和目标 Dispatcher;不存在、保留过期与暂时不可用分开表达,精确错误码待冻结。超时不走 HTTP 补查,也不证明原执行未发生。

5.4 整体补传

仅允许以 call_idsource_command_id 请求整体业务结果补传,不增加 task/execution 范围或局部筛选。固定受理截止点,重发原事件 ID/内容/版本,实时优先、分批有界;补传自身结果不能递归进入集合。

补传不是把 call.execute 重新发布来重新执行。当前MQ replay只重送已持久的原业务事实或原命令回执,重复、迟到和重启沿原消息身份恢复,不创建任务、不拨号、不新建资产。

5.5 已废弃 HTTP 入口的处理

internal/control/ HTTP业务实现及测试、Dispatcher HTTP启动路径、CLI参数和环境配置均已删除。执行、控制、查询、整体补传和AI配置/授权已有MQ本地往返、重复、错目标、迟到/重启恢复证据;不以HTTP删除代替外部SaaS验收。

6. Dispatcher → SaaSAI 配置与上传业务协调经 MQ

6.1 Dispatcher配置、临时TOKEN与recording.uploaded

  1. OSS配置唯一来自D严格JSON配置文件;A经R12取得D用官方SDK提供的15分钟预签名PUT及headers,不持长期凭据,不向SaaS申请会话或授权。字段已在v2配置Schema冻结。
  2. A仅执行一次PUT并持久记录结果,R13只报告原upload_id、binding、资产、大小与摘要;D校验后将事实和固定event_id的outbox同事务保存。
  3. D发布persistent的recording.uploaded到正确durable队列/绑定,启用mandatory并处理return。收到publisher confirm且确认未被退回,才能将本次交付记为完成;只写本地outbox或无队列的exchange不算成功。
  4. 通知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。
  5. broker故障、无绑定、confirm丢失和重启保留原消息身份并恢复通知,不重新PUT、新建资产或重新拨号。源文件与恢复记录在通知未确认前保留;TOKEN过期仅显式向D重申请。
  6. 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。

保留既有版本、immutablecontent_sha256config 及租户授权语义;源 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.gointernal/mq/amqp.gointernal/tenant/routing.gointernal/dispatcher/consumer.gointernal/dispatcher/dispatcher.gointernal/store/store.gointernal/store/facts.go。旧HTTP源码仅可在历史基线中查阅,不是当前运行路径。
  • 当前固定包:contracts/upstream/v1/ 下的 mq.schema.jsonevent-payloads.schema.jsonmq-topology.json、AI/Dispatcher/OSS/静态 Cell Schema 及业务正反例;归档目录中的旧 OpenAPI 和历史包不属于运行输入。