# 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-` | | `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-`,并在同一 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`