Implement Dispatcher v0.4 local state and controls

This commit is contained in:
2026-09-27 22:45:51 +08:00
parent 7653a4cea8
commit 3ea5f34583
68 changed files with 2375 additions and 460 deletions
@@ -1,6 +1,6 @@
# Dispatcher 有界接纳与控制通道改造计划 v0.1
**项目内 P1 设计计划,尚未实施;本地 F01/F07 Schema、正反例、来源/hash 与 Mock C 是实施门禁,不要求 SaaS 外部签收。** 唯一计划/台账是[plan-config-read-v0.1](../plan-config-read-v0.1.md);项目内对接结构见[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)。本文同步替代此前的 ETag/304、控制 CAS、暂停丢弃旧积压和逐条停止回执方案,不修改现行 `contracts/upstream/v1/`;外部兼容性未验证。
**历史设计计划;不能单独作为当前运行/验收状态的依据。** 当前状态以[总计划](../plan-config-read-v0.1.md)和[任务发现 v0.3](../thirds/v0.3.md)为准;下一版“重启全量、在线增量、pause/stop 全部挂断及条件清理”仅见[新计划](../plan-dispatcher-state-v0.1.md)/[v0.4 草案](../thirds/v0.4.md)。以下启动全量/水位、drain/hangup 与旧分页验收项保留历史,不作为已实现 v0.3 或 v0.4 的冻结合同。原设计 F01/F07 Schema、正反例、来源/hash 与 Mock C 是项目内门禁,不要求 SaaS 外部签收;外部兼容性仍未验证。
路径来源:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks`(含 `?after=<cursor>`)为用户给定路径;`/internal/v1/dispatcher/tenant/:tenant_id/quota` 与任务路由字段 `route_policy_id`、`caller_profile_id`、`allowed_trunk_ids` 为项目内目标,按第三方对接契约和 F01/F07 Schema 实施;不代表 SaaS 现网字段,真实兼容性另记。
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/proposals/call-execute-v0.4-proposal.schema.json",
"title": "Project-local call.execute ingress; external SaaS compatibility unverified",
"$comment": "Only replaces call.execute ingress in command-next.v0.1-proposal. This MQ command has no expiry; dispatcher must obtain and check fresh independent authorization before admission and originate. The command.result egress remains v0.1.",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "command_id", "command_type", "payload"],
"properties": {
"schema_version": {"const": "call-execute.v0.4-proposal"},
"dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"},
"tenant_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
"tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"},
"trace_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
"issued_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"},
"command_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
"command_type": {"const": "call.execute"},
"payload": {
"type": "object", "additionalProperties": false,
"required": ["task_id", "callee"],
"properties": {
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"callee": {"type": "string", "minLength": 1}
}
}
}
}
@@ -0,0 +1,11 @@
{
"schema_version": "call-execute.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"command_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"issued_at": "2026-09-27T02:00:00Z",
"command_type": "call.execute",
"payload": {"task_id": "a02", "callee": "15830461047"}
}
@@ -0,0 +1,12 @@
{
"schema_version": "call-execute.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "44444444-4444-4444-8444-444444444444",
"command_id": "77777777-7777-4777-8777-777777777777",
"issued_at": "2026-09-20T01:00:00Z",
"not_after": "2026-09-20T01:05:00Z",
"command_type": "call.execute",
"payload": {"task_id": "a01", "callee": "15003164745"}
}
@@ -0,0 +1,11 @@
{
"schema_version": "call-execute.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "44444444-4444-4444-8444-444444444444",
"command_id": "77777777-7777-4777-8777-777777777777",
"issued_at": "2026-09-20T01:00:00Z",
"command_type": "call.execute",
"payload": {"task_id": "a01", "callee": "15003164745"}
}
@@ -0,0 +1,11 @@
{
"schema_version": "call-execute.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "88888888-8888-4888-8888-888888888888",
"command_id": "99999999-9999-4999-8999-999999999999",
"issued_at": "2026-09-27T01:00:00Z",
"command_type": "call.execute",
"payload": {"task_id": "a01", "callee": "15003164745"}
}
@@ -0,0 +1,10 @@
{
"schema_version": "task-control.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "44444444-4444-4444-8444-444444444444",
"issued_at": "2026-09-27T01:00:00Z",
"command_type": "task.control",
"payload": {"task_id": "a01", "action": "pause", "active_call_policy": "drain", "reason": "paused by SaaS"}
}
@@ -0,0 +1,11 @@
{
"schema_version": "task-control.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "44444444-4444-4444-8444-444444444444",
"issued_at": "2026-09-20T01:00:00Z",
"not_after": "2026-09-20T01:05:00Z",
"command_type": "task.control",
"payload": {"task_id": "a01", "action": "stop", "reason": "stopped by SaaS"}
}
@@ -0,0 +1,10 @@
{
"schema_version": "task-control.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "44444444-4444-4444-8444-444444444444",
"issued_at": "2026-09-27T01:00:00Z",
"command_type": "task.control",
"payload": {"task_id": "a01", "action": "pause", "reason": "paused by SaaS"}
}
@@ -0,0 +1,10 @@
{
"schema_version": "task-control.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "66666666-6666-4666-8666-666666666666",
"issued_at": "2026-09-27T01:02:00Z",
"command_type": "task.control",
"payload": {"task_id": "a01", "action": "resume", "reason": "resumed by SaaS"}
}
@@ -0,0 +1,10 @@
{
"schema_version": "task-control.v0.4-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tenant_id": "33333333-3333-4333-8333-333333333333",
"tenant_key": "tenant-A",
"trace_id": "55555555-5555-4555-8555-555555555555",
"issued_at": "2026-09-27T01:01:00Z",
"command_type": "task.control",
"payload": {"task_id": "a01", "action": "stop", "reason": "stopped by SaaS"}
}
@@ -0,0 +1,9 @@
{
"schema_version": "task-discovery.v0.4-proposal",
"mode": "changes",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tasks": [
{"task_id": "a01", "tenant_id": "tenant-1", "tenant_key": "tenant-A", "status": "removed", "task_revision": 6}
],
"next_cursor": "43"
}
@@ -0,0 +1,8 @@
{
"schema_version": "task-discovery.v0.4-proposal",
"resource": "error",
"error": {
"code": "snapshot_unavailable",
"message": "Snapshot is no longer available; restart full discovery with admission closed"
}
}
@@ -0,0 +1,10 @@
{
"schema_version": "task-discovery.v0.4-proposal",
"mode": "snapshot",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"snapshot_id": "22222222-2222-4222-8222-222222222222",
"watermark": "42",
"tasks": [],
"next_page_token": null,
"unexpected_unapproved_field": true
}
@@ -0,0 +1,11 @@
{
"schema_version": "task-discovery.v0.4-proposal",
"mode": "snapshot",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"snapshot_id": "22222222-2222-4222-8222-222222222222",
"watermark": "42",
"tasks": [
{"task_id": "a01", "tenant_id": "tenant-1", "tenant_key": "tenant-A", "status": "running", "task_revision": 5}
],
"next_page_token": "page-2"
}
@@ -0,0 +1,11 @@
{
"schema_version": "task-discovery.v0.4-proposal",
"mode": "snapshot",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"snapshot_id": "22222222-2222-4222-8222-222222222222",
"watermark": "42",
"tasks": [
{"task_id": "a02", "tenant_id": "tenant-1", "tenant_key": "tenant-A", "status": "paused", "task_revision": 3}
],
"next_page_token": null
}
@@ -0,0 +1,87 @@
{
"manifest_version": "local-contract-manifest.v0.4",
"hash_algorithm": "SHA-256",
"status": "project-local proposal; external SaaS compatibility unverified",
"source": {
"path": "docs/thirds/v0.4.md",
"sha256": "85a72ab93526a49aed78b9f762b005c46eaccfecaaaff50013f3af6be18a3470"
},
"artifacts": [
{
"path": "docs/contracts/task-discovery-v0.4-proposal.schema.json",
"sha256": "95c02504d432c01ee57d8074c63ea60dc1118dd92057ae138b2d1e4340b7d73c"
},
{
"path": "docs/contracts/task-control-v0.4-proposal.schema.json",
"sha256": "5aa94f799da190a3e97c2fa4e911980ceac52662322e85bc2f57eecb5b21de3c"
},
{
"path": "docs/contracts/task-discovery-v0.3-proposal.schema.json",
"sha256": "da8eda2e8f5416b2fb35f68e09a98f37d1417a878e9271b8e6d0d6824b94814c"
},
{
"path": "contracts/upstream/v1/mq.schema.json",
"sha256": "d566899abbc8f0f8404cfe79ea58b0d1e7bf7e9fc663b09b282c15d5c74f505d"
},
{
"path": "docs/contracts/examples/task-discovery-snapshot-page1-v0.4.json",
"sha256": "5085883ed0fd591644de091b944acd9d725714b6d5c48247b0428221ebc33867"
},
{
"path": "docs/contracts/examples/task-discovery-snapshot-page2-v0.4.json",
"sha256": "064243f15bc33530d459f71c08f6b9d00fc776151ecaf4be4dc9a8582f5f993a"
},
{
"path": "docs/contracts/examples/task-discovery-changes-v0.4.json",
"sha256": "f3afc04d6cae564d31c9165e889e99c109f92c7e1bdbb3853421321963c30b6e"
},
{
"path": "docs/contracts/examples/task-discovery-error-v0.4.json",
"sha256": "71dd9986b49f56db792348443cf8595d7743447783129daf302d4550e73d5e96"
},
{
"path": "docs/contracts/examples/task-discovery-invalid-v0.4.json",
"sha256": "ff840c254f7abb1f6a2d9d472f10bda9c646f2ee6cfd485da1d4b4c04018314c"
},
{
"path": "docs/contracts/examples/task-control-pause-v0.4.json",
"sha256": "0adbeaed2517c33bd5b5b2e4e72c0c663263c4f32858b807f5f70190ad98a543"
},
{
"path": "docs/contracts/examples/task-control-resume-v0.4.json",
"sha256": "95c1cb22147c79751f09310fcade441835d02893bb6dc337fc49a1ab5e870358"
},
{
"path": "docs/contracts/examples/task-control-stop-v0.4.json",
"sha256": "d84b2be049bf9348fd67446f3e9a075df2f248777b81ed76153bbef4f4894d4d"
},
{
"path": "docs/contracts/examples/task-control-invalid-drain-v0.4.json",
"sha256": "36e6d1bee9f2255158696fb61d9754e968dafe1927145df56412ea8d23e46b39"
},
{
"path": "docs/contracts/call-execute-v0.4-proposal.schema.json",
"sha256": "0e019e1775810bfc0616683f5dd6bd49d9d97de92b9c063c56966a894d2461ed"
},
{
"path": "docs/contracts/examples/call-execute-altcallee-v0.4.json",
"sha256": "1c58585f9e30745b4ca21c029f22f416931ad40a00e24e6c8389f113e964435c"
},
{
"path": "docs/contracts/examples/call-execute-invalid-expiry-v0.4.json",
"sha256": "b332703b0113f64e1f8bb77bac2ee312d9a22d6d4a69214784f557381137edc7"
},
{
"path": "docs/contracts/examples/call-execute-old-v0.4.json",
"sha256": "8e2285c5dada3ee3e5511d5a687577142e6bdcf034da789a4a8b4001e3127327"
},
{
"path": "docs/contracts/examples/call-execute-recent-v0.4.json",
"sha256": "d03f5f4b04fde957e21ac300246098286ff6260c45b68a192bacb658af4e8cde"
},
{
"path": "docs/contracts/examples/task-control-invalid-expiry-v0.4.json",
"sha256": "614fda38465be236224858d3706e304a76dd651b905f8b84347492b68e96755e"
}
]
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/proposals/task-control-v0.4-proposal.schema.json",
"title": "Project-local inbound pause/resume/stop commands; external compatibility unverified",
"$comment": "Replaces only task.control ingress in command-next.v0.1-proposal. Existing command.result egress remains under the v0.1 contract. Pause and stop always require hangup; no active_call_policy or MQ command expiry field.",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "command_type", "payload"],
"properties": {
"schema_version": {"const": "task-control.v0.4-proposal"},
"dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"},
"tenant_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
"tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"},
"trace_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
"issued_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"},
"command_type": {"const": "task.control"},
"payload": {
"type": "object", "additionalProperties": false,
"required": ["task_id", "action", "reason"],
"properties": {
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"action": {"enum": ["pause", "resume", "stop"]},
"reason": {"type": "string", "minLength": 1, "maxLength": 512}
}
}
}
}
@@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.4-proposal.schema.json",
"title": "Project-local restart snapshot and live assignment changes; SaaS compatibility unverified",
"oneOf": [
{"$ref": "#/$defs/snapshot_page"},
{"$ref": "#/$defs/change_page"},
{"$ref": "#/$defs/error_response"}
],
"$defs": {
"dispatcher_id": {"$ref": "https://go-sip.local/contracts/proposals/task-discovery-v0.3-proposal.schema.json#/$defs/dispatcher_id"},
"cursor": {"$ref": "https://go-sip.local/contracts/proposals/task-discovery-v0.3-proposal.schema.json#/$defs/cursor"},
"task": {"$ref": "https://go-sip.local/contracts/proposals/task-discovery-v0.3-proposal.schema.json#/$defs/task"},
"snapshot_page": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "mode", "dispatcher_id", "snapshot_id", "watermark", "next_page_token", "tasks"],
"properties": {
"schema_version": {"const": "task-discovery.v0.4-proposal"},
"mode": {"const": "snapshot"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"snapshot_id": {"type": "string", "format": "uuid", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
"watermark": {"$ref": "#/$defs/cursor"},
"next_page_token": {"type": ["string", "null"], "minLength": 1, "maxLength": 256},
"tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}}
},
"$comment": "Every page has identical snapshot_id and watermark; null next_page_token ends this consistent complete inventory. No task appears twice, and removed tasks are omitted only from the complete inventory. Server retains stopped tasks with not-yet-retired queues. Semantic client checks enforce consistency."
},
"change_page": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "mode", "dispatcher_id", "next_cursor", "tasks"],
"properties": {
"schema_version": {"const": "task-discovery.v0.4-proposal"},
"mode": {"const": "changes"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"next_cursor": {"$ref": "#/$defs/cursor"},
"tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}}
},
"$comment": "Live-only event cursor; ordered complete event positions per dispatcher, no server expiry while the process is running. Removed tombstones are explicit; status values are not online control commands. A nonempty page advances the cursor, and an empty page repeats it."
},
"error_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "resource", "error"],
"properties": {
"schema_version": {"const": "task-discovery.v0.4-proposal"},
"resource": {"const": "error"},
"error": {
"type": "object", "additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": {"enum": ["invalid_request", "invalid_page_token", "snapshot_unavailable", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]},
"message": {"type": "string", "minLength": 1, "maxLength": 256}
}
}
}
}
}
}
@@ -0,0 +1,42 @@
# Dispatcher v0.4 项目内验收证据
**记录时间:**2026-09-27 UTC。仅限单 Dispatcher、单 Agent、单 Cell、单租户的隔离 Mock;没有真实 SaaS、OSS、ECS、供应商、生产部署或真实拨号。
## 合同和环境
- Go `go1.27.1 linux/amd64`;分支 `feat/dispatcher-state-v04`。未修改 `contracts/upstream/v1/`,未自动暂存或提交。
- `python3 scripts/validate-local-contracts.py`:v0.4 发现、控制、外呼三类 Schema,**10 个正例、4 个反例、20 个带 SHA-256 的文件**全部通过;内嵌 Schema 与项目内源文件逐字节一致。历史 v0.1/v0.3 校验仍通过。v0.4 是项目内提案,**不是 SaaS 已签收的外部合同**。
- `scripts/mq-integration-local.sh`:使用本地缓存的 `rabbitmq:4.1-management-alpine`(镜像 ID `sha256:fcc273cebb0880ec25845c9bfd97687122ac9cc391538f053cb5873f4181f35f`)。Mock SaaS 预建每 D 控制队列、每任务任务队列及结果队列;Dispatcher 的 RabbitMQ `configure` 权限为 `^$`,不能自建任务队列。仅在隔离本机 Docker 中测试,运行后临时容器已由脚本清理。
## 故障与恢复矩阵
| 场景 | 本地证据与结果 |
| --- | --- |
| 重启全量/多页完整性 | `TestRuntimeCommitsCompleteSnapshotBeforeControlAndTaskAdmission`:完整页先提交,控制积压处理前准入保持关闭;`TestRuntimeInterruptedSnapshotRestartsFromFirstPage`:第二页故障不提交部分清单,从第一页重读。 |
| 重启不继承旧游标、在线归属变化 | `TestRuntimeRestartDiscardsLiveCursorAndReloadsCompleteSnapshot`、`TestRuntimeCatchesChangesArrivingAfterSnapshotWatermark`、`TestRuntimeCatchesChangeCommittedDuringSnapshotPagination`:每次重启全量,运行内存水位接续,撤销不丢;Mock HTTP 实际请求 `mode=snapshot` 后 `after=42`。 |
| 状态冲突与控制积压 | `TestV04LiveChangesOnlyManageMembershipNotExistingStatus`、`TestRuntimeHTTPRunningAfterPauseNeedsMQResume`、`task_runtime_v04_control_test.go`:HTTP 增量不代替 MQ resume,状态冲突关准入;控制先于任务消费。 |
| 历史 pause/resume/stop | `TestOldQueuedPauseResumeStopPreservesOrderAndEveryReceipt`:一个月前发出的三条命令逐条按序应用并各有回执;`TestOldQueuedStopStillHangsUpAndDrains`:旧 stop 不因年龄拒绝。RabbitMQ Mock 在真实预建控制队列验证旧 stop、原消息身份重排队、stop 排空未接纳任务队列并发布回执。 |
| 挂断与确认 | `task_queue_v3_stop_active_test.go`、`task_control_v3_test.go`:pause/stop 发挂断、Agent 终态未知或失败不伪报完成;resume 不挂断;stop 回执在排空后。 |
| 历史外呼、新鲜门禁、未来命令 | `TestAcceptLocalV01CommandPersistsReceiptAndAcceptsOldBacklog`:旧外呼不因年龄拒绝;`TestOldQueuedCallRejectsCurrentlyUnlistedCallee`:积压一个月仍检查当前号码白名单;`TestOldQueuedCallOriginationRejectsPauseAndStop`、`TestOldQueuedCallOriginationRechecksBoundWindowAndClaimsOnlyOnce`:旧请求在拨号前仍检查最新任务控制和绑定的时间/授权截止,不二次发起;`TestFutureIssuedCallExecuteNeverReservesOrDials`:未来 `issued_at` 不提前接纳。`local_origination_quota_test.go` 和 `dial_policy_test.go` 另覆盖额度、任务/线路时间窗及较小通话时限。 |
| 结果发送、确认丢失与复投 | `TestLocalDispatcherMockSaaSEndToEndWithOutboxRecovery`:先存事实再 ACK,结果队列不存在时 outbox 保留,恢复后原消息发出;确认只说明 RabbitMQ 接收,不等于 SaaS 已处理。 |
| 安全的 SQLite 局部清理 | `TestTerminalTaskCacheEvictionPreservesRecoveryAndDedupRecords`:**只显式删除终止任务的任务配置副本**,命令重复投递继续使用原身份和回执;inbox、outbox、归属与租户绑定均保留。`TestTerminalTaskCacheEvictionFailClosedWhileWorkOrDeliveryIsUnknown`:未知/在途执行、额度占用、上传中和待发送结果均阻断;`TestTerminalTaskCacheEvictionDatabaseFailureRetainsSnapshot`:SQLite 写失败回滚。无现有数据自动扫描/删除。 |
| 历史任务增长 | `TestSingleTaskControlLookupUsesIndexedIdentityWithGrowingHistory`:600 条归属历史,按 Dispatcher+task ID 直接查询,SQLite 查询计划使用索引而非全表扫描。 |
## 工具检查与覆盖率
`bash scripts/acceptance-local.sh` 已通过合同校验、格式检查、`go vet ./...`、`go test -race ./...`、`go build ./...` 和 RabbitMQ 隔离集成测试;额外执行 `go test -cover` 统计相关业务模块。覆盖率以实际执行测试集为准:
| 模块 | 覆盖率 | 测试集 |
| --- | ---: | --- |
| `internal/dispatcher` | 70.3% | 隔离 RabbitMQ 集成测试 |
| `internal/store` | 67.6% | 单元测试 |
| `internal/configread` | 75.9% | 单元测试 |
| `internal/contract` | 77.4% | 单元测试 |
| `contracts` | 100.0% | 内嵌文件读写及错误路径单元测试 |
| `internal/mq` | 67.5% | 隔离 RabbitMQ 集成测试;纯单元测试单独执行不足 65%,不能混称 |
## 不可代签的外部阻断
1. SaaS/management 尚未提供或签收真实的 v0.4 分页快照、水位、增量保留和控制交接合同;本地 Schema 与 Mock 不证明接口真实兼容。
2. SaaS 应用收讫、可查询恢复依据和最长命令重投期限尚未确认。**本轮不删除**执行身份、幂等、未知占用、上传、终结事实或 outbox;RabbitMQ publisher confirm 不能替代应用收讫。任务配置副本的局部清理仅能显式调用且全部条件通过,不能宣称数据库已整体清理。
3. 真实供应商/OSS/ECS/生产部署与拨号、容量及切换均不在本次本地验收范围;需要独立授权和实测。
+1 -1
View File
@@ -1,6 +1,6 @@
# 任务发现统一事件游标分页计划(设计稿 v0.1)
> 状态:本文件保留用户确认的目标与门禁;**项目内 v0.3 合同、运行代码和本地 Mock/F06 检查已完成,外部 SaaS/management 未签收,也未部署或拨号**。当前本地任务发现依据为 [`docs/thirds/v0.3.md`](thirds/v0.3.md) 及其独立 Schema;[v0.2](thirds/v0.2.md) 仅保留历史。实施顺序及总体状态仍以 [`docs/plan-config-read-v0.1.md`](plan-config-read-v0.1.md) 为入口。
> 状态:**历史 v0.3 设计依据**;下文“当前”均指当时的 v0.3 实现,不是现行项目内运行版本。现行 Dispatcher 项目内 v0.4 已完成本地实现与隔离验收,见 [`新计划`](plan-dispatcher-state-v0.1.md)、[`v0.4 合同`](thirds/v0.4.md)及[验收证据](evidence/dispatcher-v04-local-acceptance.md);真实 SaaS/management 仍未签收、未联调,也未部署或拨号。[v0.2](thirds/v0.2.md) 与本文件 v0.3 仅保留历史;未改写已发布外部合同。
## 1. 目标和不变边界
+9 -4
View File
@@ -1,6 +1,6 @@
# Go SIP Agent:配置读取与有界外呼迭代计划 v0.1
**当前迭代唯一执行入口。状态:用户已授权 P1 本地迭代;任务发现 v0.3 项目内合同、运行与 F09/F06 本地门禁已完成,外部签收与真实切换未完成。** 现行 `contracts/upstream/v1/` 仍是旧外部基线;本文不授权真实 SaaS、生产部署、拨号或切换。分项设计:[有界消费与控制通道](architecture/Dispatcher有界接纳与控制通道改造计划_v0.1.md);其他本地 SaaS 业务契约仍按[第三方对接 v0.1](thirds/第三方对接事件与请求消费顺序_v0.1.md);任务发现现用[计划 0926](plan-0926.md)、[v0.3 项目内合同](thirds/v0.3.md)及[独立 Schema](contracts/task-discovery-v0.3-proposal.schema.json),[v0.2](thirds/v0.2.md)仅保留历史。机器校验件:[Schema/正反例](contracts/)。分项材料须与本文一起更新,不能继续使用旧 ETag/304、控制 CAS 或即时反馈目标。
**历史 v0.3 的执行入口。任务发现 v0.3 项目内合同、运行与 F09/F06 本地门禁仅保留历史证据。现行项目内 v0.4 已完成代码及隔离验收,见 [`plan-dispatcher-state-v0.1.md`](plan-dispatcher-state-v0.1.md)、[`thirds/v0.4.md`](thirds/v0.4.md)和[验收证据](evidence/dispatcher-v04-local-acceptance.md);外部 SaaS 签收与真实切换仍未完成。本文下述“当前”均指编写时的 v0.3,不能当作 v0.4 合同。** 现行 `contracts/upstream/v1/` 仍是旧外部基线;本文不授权真实 SaaS、生产部署、拨号或切换。分项设计:[有界消费与控制通道](architecture/Dispatcher有界接纳与控制通道改造计划_v0.1.md);其他本地 SaaS 业务契约仍按[第三方对接 v0.1](thirds/第三方对接事件与请求消费顺序_v0.1.md);历史 v0.3 发现依据为[计划 0926](plan-0926.md)、[v0.3 项目内合同](thirds/v0.3.md)及[独立 Schema](contracts/task-discovery-v0.3-proposal.schema.json),[v0.2](thirds/v0.2.md)也仅保留历史;现行项目内发现见上述 v0.4 合同。机器校验件:[Schema/正反例](contracts/)。分项材料须与本文一起更新,不能继续使用旧 ETag/304、控制 CAS 或即时反馈目标。
## 1. 范围和完成标准
@@ -12,7 +12,7 @@
| --- | --- |
| `/internal/v1/dispatcher/sip` | 用户指定路径;本 D 获管理面批准的 SIP 全量。 |
| `/internal/v1/dispatcher/task/:task_id` | 用户指定路径;任务归属、路由/主叫、智能体、时段及两项任务级超时。 |
| `/internal/v1/dispatcher/tasks?after=<cursor>` | 用户指定路径;首次 `after=0`、重启从已提交事件游标续读,首次与后续均按统一 `tasks[]` 分页,约每 30 秒发现新变化。 |
| `/internal/v1/dispatcher/tasks?after=<cursor>` | **历史 v0.3 运行方式**:首次 `after=0`、重启从已提交事件游标续读,首次与后续均按统一 `tasks[]` 分页。现行项目内 v0.4 改为启动 `mode=snapshot` 全量、运行时 `after=<内存游标>` 在线增量,已通过隔离 Mock;真实 SaaS 未联调。 |
| `/internal/v1/dispatcher/tenant/:tenant_id/quota` | 本地项目定义路径:取得任务后按其 `tenant_id` 读取该租户分配给本 D 的并发额度;真实 SaaS 路径兼容性未验证。 |
**任务队列及精确绑定只由 SaaS 创建、维护和退役;D 只消费,不建队、不绑定、不删除。** SaaS 先确认持久队列 ready 再发布 persistent 消息,mandatory 无 return 且 confirm 成功才记入队。D 离线时消息可积压;无队列时未成功入队的原消息由 SaaS 保留,就绪后按原身份重新发布,不能靠 D 重启倒灌。任务队列按 D+任务 ID 定位;租户身份保留原值用于核验和额度汇总。
@@ -42,7 +42,9 @@
额度成功核验后最多缓存约 60 秒且不超过响应有效截止时间;缺失、身份错配、过期或刷新失败,关闭该租户所有任务的新准入,不使用无限额/默认额,不阻塞 stop 静默排空。额度调低时不强挂已接通通话、不抹掉已占用/未知执行,直到占用降至上限以下才再接新呼叫。D 以 SQLite 单事务检查并预留租户+任务+Cell/线路/AI 等额度,不能先分别判断后并发超卖。确认通话终结并释放执行资源后释放通话额度,**不等待录音上传或 MQ 最终结果确认**;未知通话继续占额。多 D 时各份额之和≤租户总额,未经 F05 授权不开放多 D 运行。
### 3.3 暂停、恢复、停止与状态来源
### 3.3 暂停、恢复、停止与状态来源(当前已实现合同)
> 下一版目标改为 pause/stop 均挂断在途通话、resume 不挂断,且在线任务状态切换以独立控制队列为准;见 [`下一版计划`](plan-dispatcher-state-v0.1.md)。以下 drain/hangup 和发现状态规则在代码/Schema 切换前仍属**当前合同**,不是新目标已实现。
SaaS 先持久修改权威任务状态,再向独立 D 控制队列发布 `task.control`。请求不含 `command_id/expected_task_revision`,本阶段不设计控制去重;D 按任务串行处理控制与接纳,并持久记录生效状态后才 ACK/回执。
@@ -52,7 +54,9 @@ SaaS 先持久修改权威任务状态,再向独立 D 控制队列发布 `task
4. **优先级:**本地 stopped 永久高于任何 running;本地 paused 只能由有效 resume 解除。`tasks[]` 事件页及缓存更新可使状态更严格;即使较新的发现页把 `paused` 改为 `running`,也只更新 SaaS 状态和游标,不清除已持久的暂停/停止屏障。暂停只由上述 MQ `resume` 配合新鲜任务读取解除,且低于已知 `task_revision` 的状态不得覆盖新状态。冷启动恢复本地屏障并从已提交游标追平 SaaS 事件页后取更严格者;冲突/缺失关闭新准入,不丢已有执行事实。
5. **无编号乱序:**控制重投可能重复返回回执,不保证按消息身份“只处理一次”。pause/stop 先关闭准入;与 SaaS 最新状态不符、不可核验或接收乱序时保持关闭并返回明确失败,不根据到达先后自动恢复。恢复必须重新发送有效 resume 并核对最新权威状态;不引入替代 command_id 或控制 CAS。SaaS 不可把发布成功视为控制已应用,丢失回执下状态不明不得主动扩量。
### 3.4 任务发现、发布与期限
### 3.4 任务发现、发布与期限(当前 v0.3)
> 重启全量、运行中内存游标增量为 [`v0.4 未实施草案`](thirds/v0.4.md);本节只描述当前运行,不得拿旧持久游标解释新快照。
D 启动/重启从已提交的 v0.3 事件游标续读(无新游标时请求 `/internal/v1/dispatcher/tasks?after=0`),运行中约每 30 秒继续读取。首次与后续均返回统一 `tasks[]` 事件页,每页最多 256 项、每 D 活跃归属最多 256 项;`after` 是规范十进制事件序号,不是最大 task_id。非空页的 `next_cursor` 必须前进,空页的 `next_cursor` 保持请求游标并表示本轮追平;同一任务更新后可再次出现,`status=removed` 为撤销墓碑,不能根据某页未出现任务推断撤销。响应不携带 queue/key,D 按 D+task ID 推导并仅消费 SaaS 预建队列。每页任务、响应和游标同事务提交,首次未追平或任何错误页都拒绝新准入,控制 MQ 保持可用。**不设游标过期/410 自动重建,v0.2 checkpoint 不可解释成 v0.3 事件 ID;错误不可静默重置或回退旧 `changes` 路径。** SaaS 必须保证页内完整有序、旧游标及撤销墓碑长期可读;容量/灾备连续性仍待外部签收。发现项和任务配置须提供 tenant_id/tenant_key 以便核验额度;无变化不撤销消费关系。
@@ -132,6 +136,7 @@ SaaS 清单须保留已 stopped 但未排空任务;撤销握手按第三方契
| F09 | v0.3 首次多页、空页、同任务重现及墓碑、翻页中更新、断页重启、坏游标/410 失败、SQLite 写失败同事务回滚、MQ 控制竞态(含发现 `paused→running` 仅更新状态/游标、须 MQ `resume`+新鲜任务核验才恢复队列)、重复执行不重拨完成单节点/单租户本地 Mock 验证;旧 F09 v0.2 证据仅作历史。证据:[`f09-local-acceptance-v0.3.md`](evidence/f09-local-acceptance-v0.3.md)。 | `scripts/acceptance-local.sh`(格式/合同/Proto/hash、race、vet、构建、受限 RabbitMQ)通过;P1 业务包 66.4%–85.4%(MQ 含 integration)。CLI 30.0% 和历史 callruntime 61.7% 单列技术债,不声称全仓达标;SQLite 写失败用触发器注入,未物理填盘。外部有序返回/墓碑容量/真实验收未完成。 |
| F06 | Go 1.27.1 一次性本地制品/校验和将 v0.1 其他业务+v0.3 任务发现+v3 MQ 拓扑及 Proto 版本/hash 写入清单;已有 release/package/归档拒绝覆盖,制品 mixed/real 在开 DB 前拒绝;旧 CLI 和分散事件不在新运行路径。证据:[`f06-local-release-gates-v0.3.md`](evidence/f06-local-release-gates-v0.3.md)。 | `make release-check-local` 与本地 acceptance 通过;来源 dirty、`production_approval=false`。外部 SaaS 有序返回/长期墓碑容量、v0.2 旧状态受控切换、真实部署/拨号与生产签收均**未完成**;本地阻断不代签真实切换。 |
| F05 | 多D/第二租户不在本轮运行范围。 | 另获阶段与额度/资源授权。 |
| 下一版 Dispatcher 状态/临时数据(仅文档) | [`plan-dispatcher-state-v0.1.md`](plan-dispatcher-state-v0.1.md) 与 [`thirds/v0.4.md`](thirds/v0.4.md) 记录用户本轮提出的重启全量、状态来源收敛、pause/stop 强制挂断与任务结束条件清理;当前代码、Schema、F09/F06 证据**未切换**。日志全部原样的要求与现行禁令冲突,不列为已批准实施。 | 冻结全量分页与控制交接、SaaS 应用收讫/恢复依据、清理期限及新严格 Schema/Mock;TDD 实施后再重跑本地门禁。外部兼容、真实拨号和切换另签收。 |
## 9. 协作与版本记录
+44
View File
@@ -0,0 +1,44 @@
# Dispatcher 任务状态、控制与临时数据迭代计划 v0.1
**状态:项目内 v0.4 Schema、隔离 Mock、代码及本地验收已完成,证据见 [`dispatcher-v04-local-acceptance.md`](evidence/dispatcher-v04-local-acceptance.md);外部 SaaS/management 未签收、未联调。** 历史项目内 v0.3 见 [`thirds/v0.3.md`](thirds/v0.3.md),上一轮计划见 [`plan-config-read-v0.1.md`](plan-config-read-v0.1.md);已发布外部合同不因本地切换改变。下一版目标合同见 [`thirds/v0.4.md`](thirds/v0.4.md)。本计划不授权真实拨号、ECS、生产切换或清理任何现存数据。
## 1. 问题与完成定义
- 历史 v0.3 的重启从 SQLite 持久事件游标续读;项目内 v0.4 已改为**每次重启取得完整任务归属清单**,游标仅在该次进程内用于在线增量发现。发现页不再代替独立的 MQ 控制队列切换任务状态。
- 历史 `pause/stop` 可选择 `drain`;项目内 v0.4 已改为两者均挂断该任务的在途通话并核验终态,`resume` 不挂断。两类 MQ 命令均不因消息年龄失效;历史积压控制与外呼、新鲜拨号授权及未来 `issued_at` 外呼的边界需通过失败测试与回归证明。
- Dispatcher SQLite 持有接纳/幂等、未知通话占用、执行快照、录音/最终结果与 outbox;v0.4 仅增加已终止任务配置副本的显式条件清理,不自动删除现存数据。SaaS 尚无可验证的全部执行事实和应用收讫,**不能把这些 SQLite 记录当作可无条件删除的缓存**;不以日志代替事务和恢复事实。单节点 SQLite 不要求高可用,不因此允许已接纳的未知呼叫重拨。
- 历史控制处理为定位一个任务读取全部任务归属;v0.4 已按 D+task ID 直接索引查询,600 条历史任务的查询计划测试未扫描全表。
**完成标准:**项目内下一版严格合同/Schema/正反例/来源与哈希、Mock SaaS 故障矩阵先通过;再基于 TDD 替换运行路径且不保留已废弃模式。验证重启全量、在线增量、控制顺序/挂断、停止排空、历史积压外呼的新鲜授权、未来 issued_at、任务结束清理及数据库故障;至少执行本模块格式检查、`go vet ./...`、`go test -race ./...`、构建,业务模块覆盖率 ≥65%。不能将计划或 Mock 当作已交付功能/真实 SaaS 验收。
## 2. 必须先冻结的合同边界
| 项目 | 下一版目标 | 冻结前不得声称已解决的问题 |
| --- | --- | --- |
| SaaS→D 入站队列 | **两类仍保留**:每 D 一条独立控制队列 `task.control`,每归属任务一条任务队列 `call.execute`;均由 SaaS 创建/退役,D 只消费。结果另走 D→SaaS 队列。 | 不将任务列表轮询替代控制队列,不将控制队列误当任务队列。 |
| 重启全量 | 每次进程重启从 SaaS 读取本 D 的**完整、一致、有界分页**任务清单(含队列未退役的 stopped 任务),在全部页核验、应用前关闭新执行;不以本地旧游标跳过全量。 | 冻结全量分页标识/边界、水位、完整性、遗漏任务与 removed 的含义;未拿到完整清单不得从已有队列拨号。与现行 v0.3 `after=0` 事件回放不同,不能重解释旧游标。 |
| 在线增量 | 内存游标仅在运行期使用,发现**新增/撤销/归属**变化;状态切换由独立 MQ 控制队列实时处理。重启重新全量,无需为发现长期保留旧事件/无限墓碑。 | 冻结增量页丢失、无序/中断、连续性及全量重取规则;变更页 incidental `status` 不可自动解除暂停/停止,也不能成为第二个状态命令。身份或状态冲突须关闭准入并报错。 |
| 全量与控制交接 | 在重启清单核验和控制积压处理期间关闭新执行;按任务串行处理有效控制及接纳。SaaS 先持久状态再发控制;不得先开任务消费后才处理已积压控制。 | 冻结快照水位与已发布/积压控制顺序、历史积压、重复/迟到控制逐条处理的证明;MQ 命令不因时间积压过期。队列暂时为空不是 SaaS 不再发布的保证;不凭消息到达先后把旧 pause 当作最新 resume。 |
| 控制副作用 | `pause/stop` 关闭准入并对该任务所有在途通话发挂断,失败保持关闭并可追踪;`resume` 不挂断。stop 仍静默 ACK 未接纳积压,停止回执只在排空条件达成后应用。 | 删除 `drain` 作为 pause/stop 有效选项前,冻结 Agent 实际终结确认、超时/重试边界、错误回执以及已接纳通话的唯一最终结果。不能把 Agent 收到指令等同于通话已结束。 |
| 控制积压 | 不合并为“每个任务只执行最后一条”:pause 挂断后 resume、stop 不可逆和逐条控制回执有中间副作用。 | 只有另行变更为纯状态设置、移除中间副作用及原回执合同,才能重新讨论合并;本轮不做。 |
| SaaS 证据与清理 | SaaS 是业务配置与最终结果的目标来源;D 仅保存执行中所需最少的本地事务/恢复事实。 | 当前 RabbitMQ publisher confirm **不是** SaaS 应用收讫;必须先决定 SaaS 应用回执/可查询恢复事实及最长重投期限,才能冻结何时安全删除本地执行身份、已发布 outbox 和防重记录。未确认时保留并报告阻塞。 |
| 日志 | 文件系统诊断日志记录原值任务/通话/消息 ID、号码、时间、状态、错误、挂断与清理阶段;日志只用于排查。 | 用户要求“所有内容原样记录”与现行禁止密钥、私钥、完整音频/对话入日志的约束冲突,**不纳入实施目标**;该冲突未获解除前不得写入这些内容,也不得用日志代替可靠状态。 |
不为全量/增量自行猜测新 JSON 字段、SaaS 应用回执或保留期限;这些须作为 F01/F07 合同、严格 Schema、正反例与 Mock 的同一版本冻结。新合同不原地改写 `contracts/upstream/v1/` 或已运行的 `docs/contracts/task-discovery-v0.3-proposal.schema.json`,也不复活 v0.2 的旧 `changes`/410 字段来冒充新版本。
## 3. 实施顺序(项目内完成;外部签收仍未完成)
1. **合同与迁移门禁。** 与 SaaS 明确全量分页/增量连续性、归属撤销、单任务只读配置继续存在、控制队列积压及任务队列退役、SaaS 最终结果应用收讫或可恢复查询范围;发布项目内新版本 Schema、拓扑/正反例、来源/hash 与 Mock C。历史项目内 v0.3 与已发布外部 MQ v0.1 不能冒充 v0.4;已发布 upstream/v1 未修改。
2. **先写失败用例再切发现。** 重启不使用旧发现游标直接作为续读起点,先读一致全量,全部页面核验后才能接任务队列;在线增量只增删归属,不从状态字段擅自恢复暂停;失败/分页断裂关准入,控制队列照常消费。迁移时排空/确认旧任务、旧未确认占用和 outbox,不能直接删旧 SQLite 或并跑新旧发现。
3. **控制路径。** 保留独立 D 控制队列和逐条回执;暂停/停止首先关闭新执行、退回未接纳交付,及时对该任务所有在途通话挂断,终结事实另外确认;停止仍须排空未接纳队列后回 stopped/applied。历史 stop 即使积压很久仍须按序挂断、排空并回逐条结果,不能只停 consumer 留队列。两类 SaaS→D MQ 命令无 `not_after` 到期语义;历史外呼在接纳与拨号前仍按当前任务、白名单、时段、独立授权、额度及通话时限校验,失败拒绝且不自动重拨。未来 `issued_at` 的 `call.execute` 拒绝提前接纳。
4. **临时 SQLite 收敛。** 盘点每张当前表的写入/引用和所有外键,区分在途状态、未知占用、未交付结果、SaaS 已核收事实及有界防重记录;仅对满足全部安全条件的**已完成任务**批量清理。先保证幂等身份与可恢复结果不会因删除而重拨/丢失,再清配置副本/历史终结事实。清理阶段留日志、计数、耗时及失败原因;失败显式阻断或重试清理,不影响仍需恢复的记录。具体保留期和 SQLite/WAL 空间回收频率以容量测试和 SaaS 回执合同为依据,不猜定值。
5. **性能与文档。** 控制按 D+task ID 直查;在大量历史任务下验证单次控制查询不随全部历史线性增长。同步更新项目入口、任务发现/控制合同、代码发布版本与部署/测试证据;旧文档加历史标识但保留可追溯证据。
## 4. 本地验收场景
- 重启多页完整清单、分页期间新增/移除、崩溃后再次从全量恢复;运行期增量断页和旧游标故障不跳项、不误撤销;旧状态资料不能覆盖新消费状态。SaaS 停止发布前任务队列不能由 D 删除;独立控制队列即使发现不可用也能处理控制。
- 在途两通分别收到 pause、stop 后均挂断;resume 不挂断;Agent 挂断失败、响应成功但终态未知、控制回执丢失均不误报完成、不重拨。pause→resume、pause→stop、stop→resume、重复/历史积压控制按实际副作用与终态验收,不只检查最后状态。
- stop 未接纳积压静默 ACK、已接纳通话各发唯一最终结果;SaaS 停止发布/队列排空/回执/退役的顺序可追溯。历史 stop/断线重启不遗留永不排空的队列;未来 issued_at 的外呼绝不提前发出。
- 任务完成但 outbox 未确认、SaaS 尚未核收、上传中、未知呼叫、重复命令晚到和 SQLite 写失败时不误清;仅显式清理已证实安全的任务配置副本,历史任务查询使用索引;数据库整体大小/长期保留期仍依赖未签收的 SaaS 收讫与重投期限,不能宣称有界;原值诊断字段可追查,日志无实际凭据、完整音频/对话。
**外部阻断项:**真实 SaaS 尚未签收全量/水位和控制交接合同、没有可验证的 SaaS 应用收讫/恢复事实与重投期限,因此本轮仅完成项目内 v0.4 实现和隔离 Mock,不得宣称 SQLite 可随时清空或生产可用。现行单节点/单 Cell/单租户、禁止真实拨号和不自动提交/清理工作树的边界不变。
+35
View File
@@ -0,0 +1,35 @@
# SaaS ↔ Dispatcher:任务清单、控制与临时状态 v0.4(项目内目标草案)
**项目内 v0.4 Schema、隔离 Mock、运行代码及本地验收已完成(见 [`dispatcher-v04-local-acceptance.md`](../evidence/dispatcher-v04-local-acceptance.md));真实 SaaS 尚未签收或联调。** 已发布外部合同仍以 `contracts/upstream/v1/` 为基线;v0.3 历史发现见 [`v0.3`](v0.3.md),其余历史规则见[第三方对接顺序 v0.1](第三方对接事件与请求消费顺序_v0.1.md)。本文件不授权真实呼叫或部署。实施步骤与待冻字段见 [`plan-dispatcher-state-v0.1.md`](../plan-dispatcher-state-v0.1.md)。
## 1. 不变的两类 SaaS → D 队列
- 每个 Dispatcher 一条 SaaS 预建、独占的**控制队列**,接收 `task.control` 的 pause/resume/stop;每个归属任务另有一条 SaaS 预建**任务队列**,接收 `call.execute`。D 只消费,不能创建、绑定或删除。D→SaaS 的控制/命令回执和 `call.result` 仍走既有 per-D 结果路由,不能把结果队列误认为入站任务队列。
- 控制须先于任务新接纳生效,SaaS 先持久任务状态再按每任务顺序发布控制;控制回执是应用事实,RabbitMQ publisher confirm 不是 SaaS 已处理。不可将控制积压按 task 只保留最后一条:中间挂断、stop 排空及逐条回执仍须执行。
- SaaS 先停止向被 stop 的任务发布,D 持久屏障、处理在途执行并静默 ACK 所有未接纳积压;仅在原任务队列排空后回 stopped/applied,SaaS 确认回执后退役任务队列。无回执不得提前删队列。`resume` 不得重新启用本地已 stopped 的同一 task ID。
## 2. 重启全量、运行中增量(项目内已替换 v0.3 §2.5)
- **每次 D 进程启动/重启**,在关闭新执行的状态下读取 SaaS 对此 D 的完整归属清单;如需多页,各页必须属同一个有界、一致的快照。清单包含仍需排空的 stopped 任务,不能仅列 running;明确撤销后的任务不得被当成仍归属。全量页不完整、身份冲突、归属缺失或列表不可用时保持关闭,不能拿旧 SQLite 游标跳过全量。所有页核验完成后才应用任务归属,继续处理重启期间积压的控制;控制队列在发现失败时也不得静默停摆。
- 项目内拟定同一路径 `GET /internal/v1/dispatcher/tasks?mode=snapshot` 返回 `schema_version=task-discovery.v0.4-proposal`、`mode=snapshot`、`dispatcher_id`、UUID v4 `snapshot_id`、十进制事件 `watermark`、`tasks[]`、`next_page_token`(最后一页 `null`)。后续用 `?snapshot_id=<snapshot_id>&page_token=<token>` 取同一快照下一页;每页 ≤256、全 D 当前归属/待退役任务合计 ≤256(不能重复/跳项),各页 `snapshot_id/watermark` 不得变化;空页有非空 token 或有新 token 却零任务都按不完整失败。快照不可继续时返回 HTTP 409 + `snapshot_unavailable`,丢弃整个未提交快照并从头重取,**不**回退旧游标;page token 不受客户端解析。起始快照的水位覆盖快照生成前所有任务归属事件,不用任何旧 v0.3 SQLite 游标。
- 在线 `GET /internal/v1/dispatcher/tasks?after=<watermark-or-current_cursor>` 返回 `schema_version=task-discovery.v0.4-proposal`、`mode=changes`、`dispatcher_id`、`tasks[]` 和 `next_cursor`。非空页严格前进,空页等于本次请求游标;SaaS 保证每 D 事件连续、分页完整、有序、不得提前丢失未消费增量,无法满足即显式报错并关闭新执行、重新全量。HTTP 400 不合法请求、403 D 无归属、503 服务不可用;错误响应严格见 Schema,**不复用** v0.2 `changes[]`/410。具体身份验证仍按现有只读 HTTP 配置合同。
- **仅在本次进程运行期间**按发现水位定时查询增量,用于新增任务归属、撤销/退役和必要身份校验。水位不是最大 task_id;异常页/缺页不得推进游标或视为空变更。重启重新全量,不要求从上一次进程的永久事件游标续读;旧 v0.3 的 `after=0` 事件回放和 SQLite 持久游标不得被当作新全量响应。
- 任务列表的任务状态可以作为启动快照的初始状态及增量身份/一致性校验,**运行中 pause/resume/stop 由独立 MQ 控制队列生效**;增量页 `running` 不能自动解除暂停或不可逆停止。若列表状态与已应用的 MQ 控制矛盾,关闭该任务新接纳并报错、等待受控恢复,不按 HTTP 到达顺序偷偷切换状态。`GET /internal/v1/dispatcher/task/:task_id` 的授权配置和 resume 的新鲜状态核验仍保留,不等同于恢复“发现页控制状态”机制。
- 项目内发现字段已在 [`task-discovery-v0.4-proposal.schema.json`](../contracts/task-discovery-v0.4-proposal.schema.json) 独立严格声明,任务条目引用现有 v0.3 `$defs/task`,不重造业务字段;控制入站严格格式见 [`task-control-v0.4-proposal.schema.json`](../contracts/task-control-v0.4-proposal.schema.json)。机器 Schema 只约束消息结构;多页相同水位、完整性和快照与 MQ 积压的交接顺序须另由 Mock/代码验证。真实 SaaS 的这些字段、应用收讫、增量保留期限**仍未签收**,不能以项目内 Schema/Mock 自证兼容;不直接引用 v0.2 的 `changes`/410 或原地复用 v0.3 的严格 Schema。
## 3. 控制、通话与积压命令边界
- 新控制入站使用 `schema_version=task-control.v0.4-proposal`、原始信封 `dispatcher_id/tenant_id/tenant_key/trace_id/issued_at/command_type` 与严格 `payload={task_id,action,reason}`;**不再携带 `active_call_policy`**,逐条回执格式沿用现行 `command.result` v0.1,不能把旧 v0.1 控制请求误认为新请求。**有效 pause**:立即持久关闭该任务新接纳,停止消费任务队列并退回已交付但未接纳消息;向该任务所有已接纳且仍在途的执行发挂断。**有效 stop**:不可逆关闭新接纳,挂断全部在途执行,同时按 §1 排空未接纳积压;已接纳呼叫按原身份形成最终结果。两者不再提供 `drain` 行为;`resume` 仅在新鲜任务状态为 running 且授权有效、本地未 stopped 时恢复原任务队列,不挂断。
- 在途挂断的“已发命令”“Agent 已应用”和“实际通话终结”是三个不同事实;不因 RPC 成功伪称已终结。失败或终态未知时维持关闭、保留未知占用与可追查错误,何时回控制的 applied/failed、终结最长等待及 retry 边界须由新控制合同与 Agent 能力验证后冻结。不得因自动重投二次 originate。
- 两类 SaaS→D 入站命令 `task.control`、`call.execute` 均**不带 `not_after`,不因积压时间拒绝**。历史 pause/resume/stop 逐条按控制队列顺序处理,不合并、延迟或悄悄丢弃;stop 依 §1 排空未接纳积压并回逐条结果。新版外呼请求严格见 [`call-execute-v0.4-proposal.schema.json`](../contracts/call-execute-v0.4-proposal.schema.json),旧 v0.1 入站请求不能当作新版请求;已有出站 `command.result` 仍按其原合同校验。
- 未来 `issued_at` 的 `call.execute` **不得提前接纳**,这是时间先后校验而不是命令到期。历史外呼仍须在持久接纳及实际拨号前核对当前任务运行状态、原值号码白名单、任务及选中线路允许时段、SaaS 授权与额度、租户及供应商份额、任务与 AI 中较小通话时限;任何条件缺失、过期或不确定均拒绝新执行,不等下一窗口、不换线、不自动重拨。删除 MQ 消息期限不放宽这些独立授权期限。
## 4. Dispatcher SQLite 与日志
- SQLite 是单 D **临时可靠事务账本**,不要求多节点高可用,也不是 SaaS 的永久业务库。执行中仍需短期保存命令幂等、未知占用、执行固定快照、上传/最终结果以及未交付 outbox;文件日志只能排查,不能取代这些用于恢复与防重复拨号的事实。
- 本地只提供**显式且有条件**的已终止任务配置副本删除:仍有未知执行/额度、待上传或未发布 outbox 时拒绝;保留任务归属、执行快照、幂等命令、终结事实、上传、占用及 outbox。无自动扫描或迁移删除现有 SQLite 数据。SaaS 应用层回执/可查询恢复事实与最长重投期限尚未冻结;MQ publisher confirm **不等于** SaaS 收讫,不能据此删除依赖应用收讫的历史和防重证据。阻断原因、实际删除数量及 SQLite 错误可追查。
- 文件系统日志记录原值任务 ID、执行 ID、号码、状态、时间、错误及清理阶段;**不写入密钥、密码、私钥、完整用户音频或完整对话**。用户另提“全部内容无需脱敏”,与项目现行日志禁令冲突,不作为本版本合同已批准项。
## 5. 版本与验收边界
项目内 F07 的发现、控制与外呼严格 Schema、正反例、来源与 SHA-256 已校验;隔离 Mock C 与 TDD 回归已覆盖重启多页、积压控制/外呼、挂断失败、stop 排空、未来 `issued_at`、独立拨号门禁、条件清理及复投。已发布外部合同、真实单任务配置字段、SaaS 可恢复/应用回执与重投期限仍待 F01/F07 外部核对;`v0.3` 和 v0.1 历史证据不自动升级成 v0.4。真实 SaaS、生产、云和拨号仍须单独授权与验证。