docs: define SaaS-owned task queues and cursor discovery

This commit is contained in:
2026-09-23 19:14:03 +08:00
parent 3de175fe0c
commit 6e29ac87ac
3 changed files with 207 additions and 20 deletions
@@ -7,16 +7,48 @@
| 顺序 | 请求与触发 | SaaS 处理/返回 |
| --- | --- | --- |
| 1 | D 启动或配置到期,按 D 身份读取 SIP 全量(拟定 HTTP GET)。 | SaaS 返回本 D 唯一获批版本;D 核验后才能接受新执行。 |
| 2 | SaaS 将任务固定分配给一个 D,向该 D 投递 `call.execute`(现行 MQ 请求)。 | D 按消息中的租户原值和任务 ID 读取含智能体的任务配置(拟定 HTTP GET);未接纳任务可受约 60 秒缓存延迟影响,已接纳执行固定原快照。 |
| 3 | D 校验并持久处理这条呼叫命令。 | D 回传 `command.result` 作为接纳或拒绝的命令回执,不代表呼叫完成。 |
| 2(下一版拟定) | D 启动/重启 `GET /tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒 `GET /tasks?after=<cursor>`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,较小任务 ID 的更新/停止也能由变更游标发现。路径、字段、30 秒时限尚待 SaaS 签收。 |
| 3 | SaaS 将任务固定分配给一个 D,向该 D 投递 `call.execute`(现行消息格式;新队列形态拟定)。 | D 按消息中的租户原值和任务 ID 读取含智能体的任务配置(拟定 HTTP GET);未接纳任务可受约 60 秒配置缓存延迟影响,已接纳执行固定原快照。 |
| 4 | D 校验并持久处理这条呼叫命令。 | D 回传 `command.result` 作为接纳或拒绝的命令回执,不代表呼叫完成。 |
| 按需 | SaaS 投递 `task.control` 暂停、恢复或停止(现行 MQ 请求)。 | D 回传 `command.result`;停止不清空整个租户队列,属于已停止任务的积压命令逐条拒绝并 ACK。当前共享队列的及时控制屏障仍待下一轮验收。 |
| 4 | 通话终结且录音已上传 OSS,D 投递一条 `call.result`(**拟定 MQ 最终事件**)。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时的最终收口见 §4.2,时限尚未签收。 |
| 5 | 通话终结且录音已上传 OSS,D 投递一条 `call.result`(**拟定 MQ 最终事件**)。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时的最终收口见 §4.2,时限尚未签收。 |
**现行 MQ 路由(新版本变更前不动):**SaaS→D `agent-call.dispatchers.v2`,key `d.<dispatcher_id>.t.<tenant_key>.in`,归属 D 的 `agent-call.d.<dispatcher_id>.t.<tenant_key>.v2` 消费;D→SaaS `agent-call.saas.v2`,key `d.<dispatcher_id>.t.<tenant_key>.out`,SaaS 从 `agent-call.saas.events.v2` 消费。`dispatcher_id` 为唯一 UUID v4,`tenant_key` 保留原值;仅 publisher confirm **不等于** SaaS 已处理。下一版本是否复用拓扑由合同签收,不在本文中假设已上线。
### 1.1 现行 MQ 地址与 JSON 字段不是一回事
## 2. D ← SaaS:只读配置响应(拟定,非现网)
RabbitMQ 有**发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费**四步;`.in` 只是路由键中约定的“给 D”后缀,**不是** RabbitMQ 自动寻找 D 的指令。现行已发布拓扑如下;这里只是解释旧合同,**不是**下一版按任务队列的设计:
两接口均为 GET、**无请求 JSON 体**。D 使用自身 UUID 与 SECRETKEY,SIP 读本 D 全量,任务读原值 `tenant_key` + `task_id` 对应的单任务;实际 URL、请求头/参数、密钥承载方式待 SaaS 签收,本文**不虚构 HTTP 报文**。条件读取拟使用 `ETag/If-None-Match`,有效缓存约 60 秒;过期/请求失败只停新执行准入,既有执行保持已绑定快照,不妨碍 MQ 控制命令。
```text
SaaS→D exchange: agent-call.dispatchers.v2
routing key: d.<dispatcher_id>.t.<tenant_key>.in
binding key: d.<dispatcher_id>.t.<tenant_key>.in
queue: agent-call.d.<dispatcher_id>.t.<tenant_key>.v2
consumer: 对应 Dispatcher
D→SaaS exchange: agent-call.saas.v2
routing key: d.<dispatcher_id>.t.<tenant_key>.out
queue: agent-call.saas.events.v2
consumer: SaaS
```
例如 §3.1 的 JSON 带 `dispatcher_id=c046b893-8628-4589-ae50-619d049248a6`、`tenant_key=tenant-a`,SaaS 的**MQ 发布参数**就对应 `d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.in`;D 消费队列 `agent-call.d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.v2`。exchange、routing key、queue 和 binding **不在 JSON 的 `payload` 中**;JSON 的 `dispatcher_id/tenant_key` 是接收后核验身份。当前消费者启动需要 `--tenant-key` 且由 D 声明租户队列;这不是动态任务发现能力。即使 D 离线,只要队列/绑定已由有权一方预先创建并且消息持久入队,重启后仍可消费;若发布时队列不存在,事后建队**不能倒灌旧消息**,SaaS 要保留原消息并确认就绪后重投,mandatory 返回与 publisher confirm 应同时核对。`dispatcher_id` 是唯一 UUID v4;`tenant_key` 保留原值。仅 publisher confirm **不等于** SaaS 已处理。
### 1.2 下一版任务队列 KEY(项目示意,尚无发布的机器合同)
**硬边界:任务队列与绑定只由 SaaS 创建/维护/退役;D 只消费,不声明、创建、绑定或删除。** SaaS 必须先确认持久队列/精确绑定就绪,再发布 persistent 命令。下面的 `v3-draft` 名称仅展示规则,**不是现网 exchange/queue,也不是可直接上线的名字**:
```text
SaaS 创建并绑定:
exchange: agent-call.dispatchers.v3-draft
routing key: d.<dispatcher_id>.task.<task_id>.in
binding key: d.<dispatcher_id>.task.<task_id>.in
queue: agent-call.d.<dispatcher_id>.task.<task_id>.v3-draft
D 仅按 SaaS /tasks 清单中的 queue 名称开始消费。
```
`dispatcher_id` 选唯一 D,`task_id` 选该 D 的**一项任务**,`.in` 区分入站;`tenant_key` **不再参与任务队列 KEY**,仍在消息 JSON 和 `/tasks` 结果中,用于租户归属核验及跨任务汇总并发。若 task_id 有重复、点号/通配符或超长,不能直接照拼:ID 唯一性、段格式、完整 key/queue 长度及最终版本名必须先由 F07 冻结。`task.control` 另走 SaaS 创建的 **D 专用控制队列**,不能排在某个任务的呼叫积压之后;命令回执和最终结果回 SaaS 的具体新路由同样待 F07 发布,不套用这些示意名称。暂停/停止靠任务状态,**不是因为 KEY 中有 task_id 就会自动清空队列**;已停止任务的旧命令仍逐条拒绝并 ACK。
## 2. D ← SaaS:只读配置与任务发现(拟定,非现网)
前两条拟定配置接口均为 GET、**无请求 JSON 体**;下一版另拟增加 §2.6 的任务发现接口。D 使用自身 UUID 与 SECRETKEY,SIP 读本 D 全量,任务读原值 `tenant_key` + `task_id` 对应的单任务;实际 URL、请求头/参数、密钥承载方式待 SaaS 签收,本文**不虚构 HTTP 报文**。条件读取拟使用 `ETag/If-None-Match`,有效缓存约 60 秒;过期/请求失败只停新执行准入,既有执行保持已绑定快照,不妨碍 MQ 控制命令。
### 2.1 SIP 配置:200,返回本 D 的完整获批快照
@@ -124,7 +156,9 @@
}
```
**字段说明/消费动作:**- `schema_version/resource`:草案版本 `config-read.v0.1`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。
**字段说明/消费动作:**
- `schema_version/resource`:草案版本 `config-read.v0.1`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。
- `revision/snapshot_sha256/approved_at`:整份获批快照的修订、摘要、批准时间;摘要生成规则和版本来源仍待双方确定,示例摘要仅为占位值。
- `artifact`:静态 Cell 制品;`artifact_id/source_release/source_digest/approval_reference` 标识制品、来源版本/摘要和批准引用;`cell_id/revision/config_sha256` 标识执行单元及制品版本;`mode` 是 mock/real 范围;`allowed_targets` 是允许的原始号码;`load_evidence` 为可空加载证据。`trunks[]` 中 `trunk_id/provider_id/egress_pool_id` 定义线路、供应商、出口;`codec` 为 PCMA;`caller_profile_ids` 为主叫引用;`dial_prefix` 仅本线路前缀;`enabled` 是否启用;`sip_endpoint_ref/credential_ref/media_profile_id` 为连接、凭据和媒体配置引用,不传实际密码。`media_profiles` 下 `format/sample_rate_hz/channels/payload_type` 定义媒体格式。
- `trunk_details[]`:每项的 `trunk_id` 必须与 `artifact.trunks[]` 一一对应;`server_host/server_port` 为 SIP 服务端;`transport/auth_mode/registration_required` 是传输、认证和注册方式;`max_concurrent_calls` 是分配到该 D 的线路额度;`null` 代表未知,不可用于真实外呼。`caller_profiles[].caller_profile_id/caller_id` 给出主叫引用/原始标识(如含 `BD`),不可清洗成纯数字。
@@ -254,7 +288,9 @@
}
```
**字段说明/消费动作:**- `schema_version/resource/dispatcher_id/tenant_key/task_id`:版本、资源 `task_config`、归属 D、原值租户键和单任务 ID;D 必须验证请求归属。`task_revision` 是任务修订,`status` 为拟定 `running/paused/stopped/finished`;非 running 不接新呼叫。
**字段说明/消费动作:**
- `schema_version/resource/dispatcher_id/tenant_key/task_id`:版本、资源 `task_config`、归属 D、原值租户键和单任务 ID;D 必须验证请求归属。`task_revision` 是任务修订,`status` 为拟定 `running/paused/stopped/finished`;非 running 不接新呼叫。
- `name/group_id` 是名称及可空分组;`max_concurrent_calls` 是本任务额度,不等于跨任务/跨 D 总额度;`route_policy_id/allowed_trunk_ids[]` 是路由引用及可用线路列表。
- `schedule.time_zone/starts_at/ends_at` 定义时区和可空的起止时间;`weekly_windows` 按星期列出每日多个左闭右开 `{start,end}`,空数组禁呼;`excluded_dates[]` 为按 Asia/Shanghai 日期优先排除的日子。任务时段还须与线路时段相交。
- `agent.agent_version_id/content_sha256`:不可变智能体版本及内容摘要,必须与 `agent.config.agent_version_id` 对应;`authorization_id/authorization_expires_at` 为授权身份和截止时间,到期不得由缓存/304 复活。
@@ -386,6 +422,150 @@ HTTP/1.1 304 Not Modified
**字段说明/消费动作:**`schema_version/resource` 标识草案错误对象;`error.code` 是机器可读错误代码(示例 `not_assigned` 表示该任务不归此 D),`error.message` 是可读说明,不含密钥。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。
## 2.6 D ← SaaS:动态任务发现(**下一版草案,现行无此接口/Schema**)
新增第三条只读 HTTP 接口,不属于 §2 现有的两种配置响应。D 以自身身份在启动/重启时 `GET /tasks` 取得**一致全量快照 + 游标**,运行中**每 30 秒**以 `GET /tasks?after=<cursor>` 请求针对本 D 的**任务变更**。`after` 不是最大 `task_id`:已存在的小 ID 任务被暂停、停止、改派也必须返回。以下路径、JSON 键/类型、认证承载和错误码仅是项目提案,待 SaaS/F07 冻结,不代表现网已提供;每 30 秒发起请求是轮询频率,不是端到端 30 秒发现保证,也不同于单任务配置约 60 秒缓存。无请求 JSON 体,具体鉴权/分页传递方式未签收,不伪造完整 HTTP 请求头。
### 2.6.1 启动或重启:全量快照(200,拟定)
```json
{
"schema_version": "task-discovery.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"mode": "snapshot",
"snapshot_id": "snapshot-1042",
"cursor": "1042",
"tasks": [
{
"task_id": "task-a",
"tenant_key": "tenant-a",
"status": "running",
"task_revision": 1,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft"
}
},
{
"task_id": "task-old",
"tenant_key": "tenant-a",
"status": "stopped",
"task_revision": 3,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.v3-draft"
}
}
],
"next_page_token": null
}
```
**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`snapshot_id` 锁定同一次全量读取,跨页不得混杂新旧状态;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_key` 保留原值,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;`queue` 是**SaaS 已创建/绑定**的消费地址,D 只能读取,不能自行声明。`next_page_token` 非空时须在同一个 `snapshot_id` 下读完所有页再应用快照/水位,分页传递机制待签收。
### 2.6.2 每 30 秒:增量变化(200,拟定)
```json
{
"schema_version": "task-discovery.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"mode": "changes",
"from_cursor": "1042",
"next_cursor": "1044",
"changes": [
{
"cursor": "1043",
"operation": "assigned",
"task_id": "task-b",
"tenant_key": "tenant-a",
"status": "running",
"task_revision": 1,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.v3-draft"
}
},
{
"cursor": "1044",
"operation": "updated",
"task_id": "task-a",
"tenant_key": "tenant-a",
"status": "stopped",
"task_revision": 2,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft"
}
}
],
"next_page_token": null
}
```
**字段说明/消费动作:**`from_cursor` 对应请求的 `after`,`changes[].cursor` 是 SaaS 为本 D 变更生成的顺序水位,`next_cursor` 是成功处理整份回复后的下一次 `after`;`assigned` 为新归属、`updated` 为旧任务版本/状态变化。**任务 `task-a` 的 ID 比新任务旧,却仍被增量返回**,这正是不能用最大任务 ID 当游标的原因。先由 SaaS 创建/绑定任务队列并确认 ready,才能把 `assigned` 返回且开始发布;D 只消费。`stopped` 后 D 继续读该任务已积压消息、逐条拒绝并 ACK,绝不清空或删除队列。暂停/停止命令另走 SaaS 创建的 D 控制队列;30 秒任务清单轮询**不能代替即时控制**。同一租户 `task-a`、`task-b` 共同占用 `tenant-a` 额度。D 持久应用变更后才持久推进游标;分页时读完连续页,不得跳过未处理页。
### 2.6.3 任务改派/退役(200,拟定;与停止不同)
```json
{
"schema_version": "task-discovery.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"mode": "changes",
"from_cursor": "1044",
"next_cursor": "1045",
"changes": [
{
"cursor": "1045",
"operation": "removed",
"task_id": "task-old",
"tenant_key": "tenant-a"
}
],
"next_page_token": null
}
```
**字段说明/消费动作:**`removed` 是 SaaS 确认此 D 不再消费该任务的撤销记录(tombstone),**不是** `stop` 一到就立刻删除队列。必须已停止新发布、旧队列积压和未 ACK 消息处理完毕,且改派时确认旧 D 没有未知执行后再终结旧所有权;具体握手/退役合同待签收。D 只停止消费,不负责删队列;队列生命周期仍归 SaaS。
### 2.6.4 没有变更(200,拟定)
```json
{
"schema_version": "task-discovery.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"mode": "changes",
"from_cursor": "1045",
"next_cursor": "1045",
"changes": [],
"next_page_token": null
}
```
**字段说明/消费动作:**SaaS 没有新变更时水位不动;D 等下一个 30 秒周期,不因空列表删除已有消费关系。
### 2.6.5 游标失效或缺页(错误,HTTP 状态待签收)
```json
{
"schema_version": "task-discovery.v0.1-proposal",
"resource": "error",
"error": {
"code": "cursor_expired",
"message": "A full task snapshot is required."
}
}
```
**字段说明/消费动作:**`cursor_expired` 表示 SaaS 已不能提供从旧游标起的连续变更;缺页、断续或快照分页不一致也应中止增量。D 不推进错误游标,停受影响任务的新接纳并重新拉一致全量快照;不能把错误当无变更或盲目根据 RabbitMQ 队列列表发现任务。真正的错误码、游标保留期/分页格式须 F07 签收。
## 3. SaaS → D:业务命令(现行 MQ 格式;执行语义以新合同为准)
下列 JSON 是**完整 MQ 请求**。`schema_version` 指现行消息版本 `2.0`;`command_id` 是同一命令的稳定幂等身份,`command_type` 是命令类别;`dispatcher_id/tenant_id/tenant_key` 确定目标和租户;`trace_id` 关联结果;`issued_at/not_after` 限定时效;`payload` 是对应业务参数。重投同一命令不能创建第二次执行。新版去除对外查询和补传**命令**,不等于允许吞掉 MQ 重投或丢失本地恢复事实。
@@ -493,7 +673,7 @@ HTTP/1.1 304 Not Modified
}
```
**字段说明/消费动作:**`stop` 终止该任务的新呼叫准入;`active_call_policy=hangup` 结束已在途通话,若选择 `drain` 则等待自然结束。停止命令**不是清空 RabbitMQ 队列**:D 仍消费该租户队列,并对属于已停止任务的积压呼叫逐条产生拒绝回执、ACK,不影响同队列其他任务。现行共享队列尚不保证控制能超越积压执行消息,这属于下一轮门禁。
**字段说明/消费动作:**`stop` 终止该任务的新呼叫准入;`active_call_policy=hangup` 结束已在途通话,若选择 `drain` 则等待自然结束。停止命令**不是清空 RabbitMQ 队列**:现行 D 仍消费租户共享队列并逐条拒绝/ACK 该任务积压消息;下一版改为继续消费**该任务的 SaaS 所建队列**并逐条拒绝/ACK,不影响其他任务。现行共享队列尚不保证控制能超越积压执行消息,下一版 D 专用控制队列及 stop-before-accept 屏障仍待签收。
### 3.5 D → SaaS:命令处理回执 `command.result`(保留,不是通话事件)