Files
go-sip/docs/contracts/dispatcher-agent.md
T

369 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dispatcher ↔ Agent 对接契约(实现事实)
## 1. 适用范围与实现边界
当前边界是项目自有的 Unary gRPC `agent.v1.AgentControlService`。消息只携带控制数据、执行绑定、事实和上传元信息,不经过 Dispatcher 传输音频字节。
| 方向/角色 | 当前实现 |
| --- | --- |
| Dispatcher → Agent | `internal/rpc.Server` 提供 Agent 侧服务;`internal/dispatcher.AgentCoordinator` 通过配置的 endpoint 调用 |
| Agent → Dispatcher | `internal/rpc.DispatcherServer` 提供 Dispatcher 侧同名服务,仅接收事件和上传 RPC |
| 传输 | gRPC Unary + mTLS;`internal/rpc.Client` 不做业务自动重试 |
| 业务权威 | Dispatcher SQLite 管理任务、配额、事实、outbox;Agent 只维护本地会话、执行/文件恢复事实 |
| 数据面 | Agent 按 Dispatcher 下发的 grant 直接 PUT 到 OSS;Dispatcher 不接收或转发录音内容 |
精确字段号和枚举以 `proto/agent/v1/agent.proto` 为唯一源;本文不另造 protobuf。
**SaaS↔Dispatcher 边界已修订为 MQ-only**,详见 [SaaS↔Dispatcher 契约](./saas-dispatcher.md)。D 有全局唯一身份和独立接收 Topic;这不改变内部 Unary 或 Agent→OSS 直传。**OSS 配置由 D 配置文件维护,Agent 向 D 领取临时上传 TOKEN,SaaS 不再提供 OSS 配置/TOKEN。** D的签发职责保留;用户已将上传边界收缩为recording.uploaded可靠进入指定持久队列,不等待SaaS会话、verified或OSS ID,不新增VERIFYING。本文旧handler行为仅作差异记录,R13须改为以可靠入队完成,不以文档或Schema通过宣称已接通。
## 2. Service 方法与方向
| RPC | 方向 | 当前代码状态 | 接收端 |
| --- | --- | --- | --- |
| `GetAgentStatus` | Dispatcher → Agent | 已由 `AgentCoordinator.Probe` 调用 | Agent `rpc.Server` |
| `ActivateAgent` | Dispatcher → Agent | 已由 `AgentCoordinator.Activate` 调用 | Agent `rpc.Server` |
| `GetBootstrap` | Dispatcher → Agent | Agent 侧已实现;当前启动绑定流程未调用 | Agent `rpc.Server` |
| `SetAdmissionState` | Dispatcher → Agent | Agent 侧已实现;当前 `AgentCoordinator` 没有调用封装 | Agent `rpc.Server` |
| `Execute` | Dispatcher → Agent | 已调用;当前 RPC handler 只准备并记录执行状态 | Agent `rpc.Server` |
| `GetExecutionPermit` | Dispatcher → Agent | 已由 `ExecuteRaw` 调用 | Agent `rpc.Server` |
| `ApplyTaskControl` | Dispatcher → Agent | 已由 `AgentCoordinator.Control` 调用 | Agent `rpc.Server` |
| `QueryExecution` | Dispatcher → Agent | 用于超时/响应丢失后的对账 | Agent `rpc.Server` |
| `ReportExecutionEvent` | Agent → Dispatcher | 已接收、去重并生成 MQ outbox | Dispatcher `rpc.DispatcherEventServer` |
| `RequestUpload` | Agent → Dispatcher | 已接收并签发 OSS grant | Dispatcher `rpc.DispatcherUploadServer` |
| `CompleteUpload` | Agent → Dispatcher | 已持久保存上传事实并将 `recording.uploaded` 通知可靠入队 | Dispatcher `rpc.DispatcherUploadServer` |
`DispatcherServer` 对外只实现 `ReportExecutionEvent`、`RequestUpload`、`CompleteUpload`;其它 RPC 在 Dispatcher listener 上返回 `UNIMPLEMENTED`。Agent `rpc.Server` 虽实现完整 generated service,但其 upload handler 在非 `mock` 模式明确返回 `UNIMPLEMENTED`。
## 3. 连接、认证与会话
### 3.1 连接
1. Dispatcher 从受控 Agent endpoint 文件读取 `agent_id`、`cell_id`、地址和 `server_name`。
2. `rpc.DialFromFiles` 使用 CA、客户端证书、私钥和 server name 建立 TLS gRPC 连接。
3. Dispatcher 对每个 endpoint 先 `GetAgentStatus`,再以返回的 `boot_id` 调 `ActivateAgent`。
4. Dispatcher 生成本次 `dispatcher_epoch`;Agent 用 `session_generation` 持久化 fencing 水位。`dispatcher_epoch` 是运行代次,不是全局唯一的 Dispatcher 逻辑 ID;后者的 MQ 关联及与内部会话的绑定待新契约冻结,当前 Proto 未因此自动增加字段。
5. Agent 新会话会 fence 旧的 `agent_id + cell_id + boot_id + epoch + generation` 组合;旧请求返回 `ABORTED`,不会自动释放未知执行。
### 3.2 mTLS 与身份
- Agent listener 要求 verified peer certificate;可按证书 fingerprint 和允许的 Agent ID 限制。
- Dispatcher listener 同样要求 verified mTLS peer、fingerprint allowlist 和可选的 `AllowedAgentIDs`。
- 共用证书只证明证书组;`agent_id`、`cell_id`、`boot_id` 必须经过 Dispatcher 激活绑定,不能信任 Agent 自报 endpoint。
- gRPC RPC 失败不等于业务失败。尤其 `Execute`、permit 和上传完成超时后,调用方必须查询原执行/上传状态,不能换 execution ID、attempt ID 或 upload ID。
## 4. 公共消息结构
### 4.1 `RequestMeta`
| 字段 | 类型 | 用途 |
| --- | --- | --- |
| `protocol_version` | string | 当前实现发送 `agent.v1` |
| `request_id` | string | 单次 RPC 请求关联 |
| `trace_id` | string | 跨模块追踪 |
| `operation_id` | string | 业务操作标识;写操作必填 |
| `deadline_unix_ms` | int64 | 协议字段;当前 handler 未单独执行该值的截止检查 |
| `dispatcher_epoch` | string | 当前 Dispatcher 会话代次 |
| `agent_id` / `cell_id` | string | endpoint 绑定身份 |
| `boot_id` | string | Agent 进程启动身份 |
| `session_generation` | uint64 | 会话 fencing 代次 |
| `idempotency_key` | string | 同操作重报必须复用;写操作必填 |
### 4.2 `ResponseMeta`、`Failure`、`OperationReceipt`
`ResponseMeta` 回显协议、请求、trace、operation、Dispatcher epoch、Agent/Cell/boot/generation,并增加 `observed_at_unix_ms`。
`Failure`:
| 字段 | 类型 |
| --- | --- |
| `code` | `FailureCode` |
| `retryable` | bool |
| `detail` | string |
| `field` | string |
`OperationReceipt`:`meta`、`result`、可选 `failure`、`fact_id`、`content_sha256`、`accepted_at_unix_ms`。
`ResultCode` 为 `ACCEPTED`、`APPLIED`、`REJECTED`、`UNKNOWN`、`CONFLICT`;`ACCEPTED` 只表示接收/持久记录,不能直接解释为已拨号或已挂断。
### 4.3 `ExecutionBinding`
| 字段 |
| --- |
| `tenant_id`, `tenant_key` |
| `execution_id`, `task_id`, `task_item_id` |
| `task_revision` |
| `call_id`, `attempt_id` |
| `agent_version_id` |
| `route_policy_id`, `caller_profile_id` |
Dispatcher 从已校验的 `call.execute` 构造 binding;Agent 回报不能改写租户、任务或资产归属。
### 4.4 `AssetDescriptor`、`ExecutionFact`、`UploadGrant`
`AssetDescriptor`:
| 字段 | 类型/约束 |
| --- | --- |
| `kind` | `RECORDING` 或 `TRANSCRIPT` |
| `asset_id`, `call_id`, `execution_id` | string |
| `format` | string |
| `size_bytes`, `duration_ms` | int64 |
| `checksum_sha256` | string |
| `channels`, `sample_rate_hz` | int32 |
`ExecutionFact`:
| 字段 | 类型/约束 |
| --- | --- |
| `fact_id` | 稳定事实 ID,必填 |
| `content_sha256` | 事实内容摘要,必填 |
| `binding` | `ExecutionBinding` |
| `kind` | `FactKind` |
| `observed_at_unix_ms` | Agent 事实发生时间,必填 |
| `source_boot_id` | 事实来源 boot,必填 |
| `source_sequence` | uint64,诊断/排序关联 |
| `payload_json` | JSON object 字节,必填 |
`UploadGrant`:`upload_id`、`target_url`、`headers[]`、`expires_at_unix_ms`、`object_key`、`required_checksum_sha256`、`max_bytes`。grant 不包含长期 OSS 密钥。
## 5. Dispatcher → Agent RPC
### 5.1 `GetAgentStatus`
请求:`RequestMeta` + 可选 `AgentBinding target`。激活前仅发送 protocol/request/trace/operation/agent/cell,不能带 session binding。
响应:`ResponseMeta` + `AgentStatus`:
- `agent_id`、`cell_id`、`boot_id`;
- `software_version`、`protocol_version`、`asterisk_version`;
- `admission_state`;
- `capabilities[]`;
- `resources`(CPU、内存、FD、spool、媒体端口及 `sample_fresh`);
- `applied_configs[]`;
- `mtls_authenticated`、`session_active`、`status_reason`。
当前 `Probe` 至少校验返回的 Agent/Cell 与目标一致且 `boot_id` 非空。
### 5.2 `ActivateAgent`
请求:`RequestMeta`、`AgentBinding`、`activation_operation_id`、可选 `session_nonce`、`session_expires_at_unix_ms`。
`AgentBinding` 由 Dispatcher 提供:`agent_id`、`cell_id`、`expected_boot_id`、`dispatcher_epoch`、`session_generation`、`endpoint_id`。
响应:`ResponseMeta`、`state=ACTIVE`、`Session`:
- `dispatcher_epoch`;
- `session_generation`;
- `expires_at_unix_ms`;
- `session_credential`(bytes,敏感数据)。
当前 Agent 实现实际将 session 有效期固定为 `now + 10 分钟`;`session_nonce` 和调用方提供的 `session_expires_at_unix_ms` 当前没有参与校验。后续请求由 mTLS + 会话绑定字段授权,当前 handler 未在每个请求中再次传输或校验 `session_credential`。
### 5.3 `GetBootstrap`
请求:`RequestMeta`、`agent_id`、`cell_id`、`boot_id`、`session_generation`。当前 Agent handler 以 `RequestMeta` 会话授权为准。
响应:`ResponseMeta`、`state`、`runtime_configs[]`、`UploadPolicy`:
- `ConfigReference`:`kind`、`version`、`sha256`、`source`;
- `UploadPolicy`:`enabled`、`max_asset_bytes`、`allowed_hosts[]`。
当前 Dispatcher 启动绑定流程只执行 status + activate,没有调用 bootstrap。
### 5.4 `SetAdmissionState`
请求字段:`meta`、`target`、`state`(`OPEN/CLOSED/DRAINING/QUARANTINED`)、`barrier_id`、`expected_admission_generation`、`reason`。
响应:`OperationReceipt` + `applied_admission_generation`。Agent 侧按目标 Agent 做 generation CAS;代次不匹配返回 `CONFLICT`。当前代码没有 `AgentCoordinator` 的调用封装。该方法属于 D→A 内部准入职责,不把它直接等同 SaaS 任务控制;SaaS 业务控制只能经 MQ 进入 D,旧 HTTP 控制入口应移除。
### 5.5 `GetExecutionPermit`
请求:`meta`、`ExecutionBinding`、`resource_reservation_id`、`expected_task_revision`、`admission_generation`、`config_sha256`。
响应:`OperationReceipt` + `ExecutionPermit`:
| 字段 | 含义 |
| --- | --- |
| `permit_id` | 当前实现为 `permit-<execution_id>` |
| `resource_reservation_id` | 对应 Dispatcher reservation |
| `issued_at_unix_ms` / `expires_at_unix_ms` | 许可时间窗 |
| `dispatcher_epoch` / `session_generation` | fencing 绑定 |
| `fencing_token` | 随机 fencing token |
| `config_sha256` | 执行配置摘要 |
当前 `rpc.Server` 的 permit 默认有效期为 1 秒;Dispatcher 在收到传输错误时不重新申请另一个 reservation,而是先 `QueryExecution` 对账。
### 5.6 `Execute`
请求字段:`meta`、`ExecutionBinding`、`call_execute_json`、`config_sha256`、`admission_generation`、`resource_reservation_id`、`permit_id`。
`call_execute_json` 必须是原始、已通过 `mq.schema.json` 的 `call.execute` body。Agent 当前会再次解码并校验:租户、execution、task、task item、AI version 必须与 binding 一致,并在已提供本地 AI 快照/授权时校验摘要、版本、租户、模式、有效期和 egress pool。
成功响应:`OperationReceipt` + `state`。当前 `rpc.Server.Execute` 的可证明副作用是:
- 写入执行准备日志;
- 写入内存 execution record;
- 同 idempotency key 同内容返回原 receipt;不同内容返回 `CONFLICT`;
- 在非 mock 模式检查 Asia/Shanghai 外呼时间窗口。
当前该 RPC handler 本身不证明已调用 ARI/RTP 或已经产生 SIP 外呼。`AgentCoordinator.ExecuteRaw` 当前发送 binding、原始 command JSON、config digest 和 permit ID,不填充 `admission_generation` 与 `resource_reservation_id`。
### 5.7 `ApplyTaskControl`
请求:`meta`、`ExecutionBinding`、`action`(`PAUSE/RESUME/STOP`)、`active_call_policy`(`DRAIN/HANGUP`)、`expected_task_revision`、`reason`。
响应:`OperationReceipt`、`applied_task_revision`、`state`。
当前 Agent handler:
- 找不到 execution 返回 `NOT_FOUND`;
- task revision 不一致返回 `CONFLICT`;
- terminal execution 不允许 resume;
- `STOP` 将 call state 置为 `stopped`/terminal;
- `PAUSE`/`RESUME` 更新内存 call state。
当前 handler 没有依据 `active_call_policy` 实施实际 drain/hangup,也没有把 `reason` 写入业务事实;真正通话屏障仍需 ARI/执行器事实补齐。
### 5.8 `QueryExecution`
请求:`meta` + `ExecutionBinding`。
成功响应包含 `ExecutionSnapshot`:binding、execution state、`call_state`、`attempt_id`、`reason_code`、`observed_at_unix_ms`、`unknown`、`assets[]`。
当前 handler 查询 Agent 内存 execution record;未找到返回 `NOT_FOUND`,当前实现不会填充资产列表。Dispatcher 只在原 RPC 结果未知或回包不完整时用原 binding 对账,不能据此自动重拨。
## 6. Agent → Dispatcher RPC
### 6.1 `ReportExecutionEvent`
请求:`RequestMeta` + `ExecutionFact`。
Dispatcher 侧额外要求:
- verified mTLS peer;可选允许的 Agent ID;
- `meta.agent_id/cell_id/boot_id` 非空;
- operation/idempotency key 非空;
- fact ID、内容摘要、source boot、观测时间和 payload 非空;
- binding 至少包含 `tenant_id`、`tenant_key`、`execution_id`;
- `payload_json` 必须解码为 JSON object。
`FactKind` 到 SaaS MQ 事件的当前映射:
| FactKind | Dispatcher 输出 | aggregate |
| --- | --- | --- |
| `EXECUTION_ACCEPTED` | `command.result` | `command` |
| `CALL_STATUS` | `call.status` | `call` |
| `CALL_FINISHED` | `call.finished` | `call` |
| `TRANSCRIPT_UPDATED` | `transcript.updated` | `transcript_segment` |
| `TRANSCRIPT_FAILED` | 尝试生成 `transcript.failed`,但当前 `aggregate_type=transcript` 不通过 `mq.schema.json` | 当前路径阻塞 |
| `CONTACT_OPT_OUT` | `contact.opt_out` | `call` |
| `RECORDING_PROGRESS` | 不发布 MQ 事件,只保存 fact | `execution_fact` |
当前 Dispatcher 为有事件的 fact 生成 `event_id = execution-fact-<fact_id>`,并在同一 SQLite 事务内。注意:`TRANSCRIPT_FAILED` 的当前代码映射使用 `aggregate_type=transcript`,而 `mq.schema.json` 只允许 `transcript_segment`;因此该 fact 当前会在事件 Schema 校验阶段失败,不能按成功回报处理。
具体流程为:
1. 以 `fact_id + content_sha256` 去重;
2. 保存 binding、payload、source boot、source sequence、观测时间;
3. 分配 aggregate version;
4. 写入权威事件 outbox。
重复 fact 同摘要返回 `ACCEPTED`;同 fact ID 不同摘要返回 `CONFLICT`。Agent 不能通过 payload 或请求字段指定 `aggregate_version`。
### 6.2 `RequestUpload`
请求:`meta`、`ExecutionBinding`、`AssetDescriptor`、`upload_id`。
当前 Dispatcher 验证:Agent/Cell/operation/idempotency 元数据、完整 execution/tenant binding、合法 `tenant_key`、asset ID、upload ID、正数文件大小和 SHA-256;超过 OSS 最大文件大小返回 `RESOURCE_EXHAUSTED`。
成功响应:`OperationReceipt`、`UploadGrant`、`state`。D读取严格JSON配置文件,复用官方SDK提供15分钟预签名PUT;本地签发、单次上传及通知恢复证据见[上传验证](../evidence/20260921-mq-upload-progress.md)。当前handler:
- 以 `tenant_key + "\\0" + execution_id + "\\0" + asset_id` 的 SHA-256 hex 生成 object key,并加配置的 key prefix;
- 将 binding、asset、grant、object key、state 持久到 Dispatcher SQLite;
- grant有效期固定15分钟,不允许通过配置改变;
- 同upload ID、同operation ID及原请求正文返回原grant,包括原到期时间;绑定或正文不同返回冲突;
- 重新签发必须使用显式新请求身份,保持原资产及对象绑定;不因原请求重放自动续期,不向SaaS申请TOKEN。
新请求当前返回 `UPLOAD_STATE_REQUESTED`;持久层状态为 `granted`。上传通知可靠入队后返回 `UPLOAD_STATE_COMPLETED`,失败返回 `UPLOAD_STATE_FAILED`。
目标流程为 **D 依据自身配置文件向 A 提供临时上传 TOKEN,不向 SaaS 申请 OSS 配置/TOKEN**。D 侧配置缺失/无效时明确失败,不切换配置源;长期凭据不交给 A,不写入示例、日志或证据。当前TOKEN形态为官方SDK生成的受限预签名PUT信息,映射到UploadGrant;它不是OSS原生强制一次性凭据,Agent通过持久尝试状态保证每次授权尝试最多一次PUT。
用户已收缩上传职责:**R12不申请SaaS会话,不等待SaaS回复**。D校验原租户/执行/资产后按自身配置提供15分钟SDK预签名PUT,保留原upload_id;过期仅显式向D重新申请。签发能力保留复用,不引入第二配置源或新的上传控制协议。
### 6.3 Agent → OSS 直接上传
Agent 获得 grant 后使用 `internal/agent.UploadClient.UploadFile`:
- 只允许 HTTPS;除非显式配置,否则不允许 HTTP;
- 可限制目标 host;禁止 grant 注入 `Host` 和 `Content-Length`;
- 使用同一文件描述符预读校验,再按`Content-Length` PUT并对实际发送字节计数和计算SHA-256;文件变化明确失败;
- 禁止重定向;2xx 才算 PUT 成功;
- 返回 HTTP status、文件大小、SHA-256、ETag;
- 文件内容不经过 Dispatcher,Agent 不把源文件删除或移动。
PUT成功仅是文件上传事实,还须由D将通知可靠送入MQ;既不等待也不声称SaaS已应用。
### 6.4 `CompleteUpload`
请求:`meta`、原 `ExecutionBinding`、原 `AssetDescriptor`、`upload_id`、`uploaded_size_bytes`、`uploaded_checksum_sha256`。
Dispatcher当前执行:
1. 校验原upload ID、完整binding/asset、实际上传大小、SHA-256和grant的max_bytes;只接受录音事实。
2. 使用签发时持久保存的bucket/object key,不因当前配置改变对象位置。
3. 在同一SQLite事务内保存原上传事实及固定event_id的recording.uploaded outbox。
4. 未确认入队时返回Unavailable并保留uploaded状态;publisher确认原通知可靠入队后,重复R13返回ACCEPTED及COMPLETED。
完成依据是persistent消息进入指定durable队列/绑定、mandatory无return且publisher confirm成功。仅写本地outbox不算交付完成。通知恢复不要求重取TOKEN或重新PUT,也不因原grant此时过期而重传已上传的文件。
旧OSS HEAD/verified及oss_id响应已删除,Proto保留原字段编号和名称为reserved。不等待SaaS会话或消费回复,不新增VERIFYING、不发recording.ready;完成不表示SaaS已处理。当前MQ wire为2.0,运行使用唯一 V1 契约包(包含 AI JCS 摘要规则),上传字段沿用已批准的[V1冻结方案](mq-only-v1-freeze-proposal.md)。本地MQ无绑定、确认丢失、重启及原通知恢复已有[证据](../evidence/20260921-mq-upload-progress.md),不等于真实OSS/SaaS联调通过。
## 7. 错误、幂等与未知结果
### 7.1 gRPC FailureCode
`INVALID_ARGUMENT`、`UNAUTHENTICATED`、`PERMISSION_DENIED`、`FAILED_PRECONDITION`、`ABORTED`、`RESOURCE_EXHAUSTED`、`UNAVAILABLE`、`DEADLINE_EXCEEDED`、`NOT_FOUND`、`ALREADY_EXISTS`。
调用方处理原则:
- 参数、Schema、绑定错误:拒绝,不改写成另一任务;
- CAS、session、fact、upload 绑定冲突:查询原对象,不换 ID 绕过;
- `UNAVAILABLE`/`DEADLINE_EXCEEDED`:结果可能已发生,先查询/对账;
- `Execute` 结果未知:Dispatcher 标记 reservation/task 为 unknown,禁止自动重新 originate;
- 上传回包丢失:复用原 `upload_id` 和原资产摘要;
- Agent 新 boot:保留旧未知占用,先恢复和对账,不因新 boot 的空状态释放配额。
### 7.2 RPC 幂等键
Agent 侧写操作的内存 operation key 为:
`agent_id + "\\0" + operation_id + "\\0" + idempotency_key`
同 key 同 protobuf 内容返回原 receipt;同 key 不同内容返回 `CONFLICT`。Dispatcher 事件侧使用 `fact_id + content_sha256`,上传侧使用 `upload_id + binding + asset` 绑定。
## 8. 当前未实现或未接通的部分
1. `GetBootstrap`、`SetAdmissionState` 虽有 handler,但当前 Dispatcher 启动流程没有调用完整 bootstrap/admission 编排。
2. `Execute` 的当前 RPC 实现只证明准备/幂等/配置校验,不证明 ARI/RTP/SIP 已由该 RPC 直接完成。
3. D配置文件→15分钟TOKEN→A直传及recording.uploaded可靠入队/R13完成已接通并有本地MQ证据。保留D签发,删除旧ready/oss_id完成路径;不开发SaaS上传会话或verified往返,不新增VERIFYING,旧上传HTTP继续废弃。
4. SaaS AI 配置/授权的 MQ 请求响应与持久绑定已有本地RabbitMQ/SQLite恢复证据;Agent动态交付仍受现有Unary快照载体边界约束。旧 AI version GET 已废弃,不能作为后续实现方向。
5. 不能把 generated service 中的全量方法数当作每个 listener 都可调用;实际 listener 能力以第 2 节和 `DispatcherServer` 代码为准。
## 9. 依据文件
- `proto/agent/v1/agent.proto`
- `internal/dispatcher/agent.go`
- `internal/rpc/client.go`
- `internal/rpc/server.go`
- `internal/rpc/dispatcher_server.go`
- `internal/rpc/dispatcher_events.go`
- `internal/rpc/dispatcher_upload.go`
- `internal/agent/upload.go`
- `internal/store/facts.go`
- `internal/store/uploads.go`
- `internal/ai/snapshot.go`
- `internal/ai/authorization.go`
- `cmd/sip-go-agent/main.go`
- `contracts/upstream/v1/event-payloads.schema.json`
- `contracts/upstream/v1/ai-authorization.schema.json`