feat(agent-call): add contract-driven mock executor

This commit is contained in:
2026-09-12 19:29:49 +08:00
parent 6cd28c033e
commit d4ac130a47
47 changed files with 5936 additions and 30 deletions
+5
View File
@@ -0,0 +1,5 @@
# Contract fixtures
`call.execute.json` is the canonical valid command fixture. Runtime tests generate the remaining event fixtures from persisted facts so replay assertions compare original bytes and IDs rather than synthesized history. Invalid cases include unknown schema versions, missing required fields, cross-tenant bindings, conflicting idempotency bodies, and tenant routing keys over 224 UTF-8 bytes.
All fixtures are synthetic. The profile is `mock`; it is never a production provider configuration.
+23
View File
@@ -0,0 +1,23 @@
{
"schema_version": "1.0",
"command_type": "call.execute",
"command_id": "cmd_demo_001",
"tenant_id": "tenant-demo",
"tenant_key": "tenant-demo-key",
"trace_id": "trace_demo_001",
"issued_at": "2026-09-11T08:00:00Z",
"not_after": "2099-09-11T08:05:00Z",
"payload": {
"execution_id": "exec_demo_001",
"task_id": "task-demo",
"task_item_id": "item_demo",
"task_revision": 1,
"callee": "18601013734",
"route_policy_id": "route_policy_test",
"caller_profile_id": "caller_profile_test",
"agent_version_id": "agent_v1",
"variables": {},
"ring_timeout_ms": 30000,
"max_call_duration_ms": 180000
}
}
+303
View File
@@ -0,0 +1,303 @@
# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema
openapi: 3.1.0
info:
title: agent-call Executor Control API
version: 1.0.0
description: >-
Internal control, query, replay, and recording hand-off API. Call execution
enters through RabbitMQ, not HTTP.
servers:
- url: https://executor.internal
security:
- bearerAuth: []
paths:
/internal/v1/outbound/tasks/{task_id}/controls:
post:
operationId: controlTask
summary: Persist a pause, resume, or stop barrier
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/TaskId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ControlRequest'
responses:
'202':
description: Reliably persisted, not yet necessarily applied
headers:
Location:
schema: {type: string}
content:
application/json:
schema: {$ref: '#/components/schemas/ControlAccepted'}
'409': {$ref: '#/components/responses/Conflict'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/v1/outbound/commands/{command_id}:
get:
operationId: getCommand
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/CommandId'
responses:
'200':
description: Command snapshot
content:
application/json:
schema: {$ref: '#/components/schemas/Command'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/v1/outbound/calls/{call_id}:
get:
operationId: getCall
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/CallId'
responses:
'200':
description: Call snapshot
content:
application/json:
schema: {$ref: '#/components/schemas/Call'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/v1/outbound/calls/{call_id}/replays:
post:
operationId: replayCall
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/CallId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayRequest'}
responses:
'202':
description: Replay persisted for bounded broker delivery
headers:
Location: {schema: {type: string}}
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayAccepted'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
'410': {$ref: '#/components/responses/ReplayExpired'}
/internal/v1/outbound/commands/{source_command_id}/replays:
post:
operationId: replayCommand
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/SourceCommandId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayRequest'}
responses:
'202':
description: Replay persisted for bounded broker delivery
headers:
Location: {schema: {type: string}}
content:
application/json:
schema: {$ref: '#/components/schemas/ReplayAccepted'}
'401': {$ref: '#/components/responses/Unauthorized'}
'404': {$ref: '#/components/responses/NotFound'}
'410': {$ref: '#/components/responses/ReplayExpired'}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
parameters:
TenantId:
name: X-Tenant-ID
in: header
required: true
schema: {type: string}
RequestId:
name: X-Request-ID
in: header
required: true
schema: {type: string}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string}
TaskId:
name: task_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
CommandId:
name: command_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
SourceCommandId:
name: source_command_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
CallId:
name: call_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
schemas:
Id:
type: string
minLength: 1
maxLength: 128
pattern: '^[^\\s/\\\\]+$'
ControlRequest:
type: object
additionalProperties: false
required: [command_id, action, expected_task_revision, reason]
properties:
command_id: {$ref: '#/components/schemas/Id'}
action: {type: string, enum: [pause, resume, stop]}
expected_task_revision: {type: integer, minimum: 1}
active_call_policy: {type: string, enum: [drain, hangup]}
reason: {type: string, minLength: 1, maxLength: 512}
ControlAccepted:
type: object
required:
- command_id
- tenant_id
- tenant_key
- task_id
- status
- requested_task_revision
- accepted_at
properties:
command_id: {$ref: '#/components/schemas/Id'}
tenant_id: {type: string}
tenant_key: {type: string}
task_id: {$ref: '#/components/schemas/Id'}
status: {const: accepted}
requested_task_revision: {type: integer}
accepted_at: {type: string, format: date-time}
ReplayRequest:
type: object
additionalProperties: false
required: [command_id, reason]
properties:
command_id: {$ref: '#/components/schemas/Id'}
reason: {type: string, minLength: 1, maxLength: 512}
ReplayAccepted:
type: object
required: [command_id, status, snapshot_cutoff]
properties:
command_id: {$ref: '#/components/schemas/Id'}
status: {const: accepted}
snapshot_cutoff: {type: string, format: date-time}
Command:
type: object
required:
- command_id
- command_type
- tenant_id
- tenant_key
- status
- aggregate_version
properties:
command_id: {$ref: '#/components/schemas/Id'}
command_type: {type: string}
tenant_id: {type: string}
tenant_key: {type: string}
task_id: {type: [string, 'null']}
execution_id: {type: [string, 'null']}
call_id: {type: [string, 'null']}
status: {type: string}
reason_code: {type: [string, 'null']}
wait_reason_code: {type: [string, 'null']}
accepted_at: {type: [string, 'null'], format: date-time}
waiting_since: {type: [string, 'null'], format: date-time}
admission_deadline: {type: [string, 'null'], format: date-time}
requested_task_revision: {type: [integer, 'null']}
applied_task_revision: {type: [integer, 'null']}
task_state: {type: [string, 'null']}
updated_at: {type: string, format: date-time}
aggregate_version: {type: integer, minimum: 1}
Call:
type: object
required:
- call_id
- execution_id
- call_state
- call_version
- attempts
- transcript
- recordings
- delivery
- snapshot_at
properties:
call_id: {type: string}
execution_id: {type: string}
task_id: {type: string}
task_item_id: {type: string}
call_state: {type: string}
call_version: {type: integer}
reason_code: {type: [string, 'null']}
outcome: {type: [string, 'null']}
started_at: {type: [string, 'null'], format: date-time}
ended_at: {type: [string, 'null'], format: date-time}
duration_ms: {type: [integer, 'null']}
attempts: {type: array, items: {type: object}}
transcript: {type: object}
recordings: {type: array, items: {type: object}}
delivery: {type: object}
snapshot_at: {type: string, format: date-time}
responses:
Unauthorized:
description: Unauthorized
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Forbidden:
description: Forbidden
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
NotFound:
description: Not found without cross-tenant enumeration
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Conflict:
description: Idempotency or revision conflict
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
ReplayExpired:
description: Retention window expired
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Problem:
type: object
additionalProperties: false
required: [type, title, status, code, detail, request_id, retryable]
properties:
type: {type: string, format: uri-reference}
title: {type: string}
status: {type: integer}
code: {type: string}
detail: {type: string}
request_id: {type: string}
retryable: {type: boolean}
+54
View File
@@ -0,0 +1,54 @@
{
"profile_version": "1.0.0",
"mode": "mock",
"provider_modes": {
"saas": "mock",
"database": "sqlite",
"rabbitmq": "memory",
"sip": "mock",
"asterisk": "mock",
"asr": "mock",
"llm": "mock",
"tts": "mock",
"oss": "mock",
"cloud": "fake-cli"
},
"versions": {"schema": "1.0", "service": "0.1.0", "seed": "mock-2026-09-11"},
"limits": {
"admission_window_s": 30,
"ring_timeout_ms": 30000,
"max_call_duration_ms": 180000,
"max_mq_bytes": 262144,
"max_queue_messages": 1000,
"max_http_bytes": 65536,
"recording_max_bytes": 16777216,
"replay_retention_s": 604800,
"upload_ttl_s": 300,
"global_concurrency": 6,
"global_cps": 3,
"tenant_concurrency": 2,
"tenant_cps": 1,
"cell_capacity": 4,
"turns": 2,
"hold_ms": 0
},
"random_seed": 7,
"tenants": [
{"tenant_id": "tenant-demo", "tenant_key": "tenant-demo-key", "enabled": true},
{"tenant_id": "tenant-b", "tenant_key": "tenant.b", "enabled": true},
{"tenant_id": "tenant-c", "tenant_key": "tenant#c", "enabled": true}
],
"tasks": [
{"task_id": "task-demo", "tenant_id": "tenant-demo", "state": "running", "revision": 1},
{"task_id": "task-b", "tenant_id": "tenant-b", "state": "running", "revision": 1},
{"task_id": "task-c", "tenant_id": "tenant-c", "state": "running", "revision": 1}
],
"routes": [{"route_policy_id": "route_policy_test", "trunk_id": "trunk-mock", "egress_pool_id": "egress-mock", "dial_prefix": "7089", "allowed": true}],
"caller_profiles": [{"caller_profile_id": "caller_profile_test", "display": "BD93205882", "allowed": true}],
"agents": [{"agent_version_id": "agent_v1", "immutable": true, "llm": "mock", "tts": "mock", "asr": "mock"}],
"cells": [
{"cell_id": "cell-a", "capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"},
{"cell_id": "cell-b", "capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"}
],
"failure_scenarios": ["success", "busy", "no_answer", "ai_timeout", "ari_disconnect", "upload_missing", "upload_bad_checksum", "broker_outage", "clock_jump"]
}
+40
View File
@@ -0,0 +1,40 @@
# agent-call MQ topology v1.0
This file is an implementation companion to the field authority in
`SaaS交互_OpenAPI与MQ契约规划_v0.1.md`.
| Element | Value |
| --- | --- |
| Namespace | `agent-call` |
| Command exchange | `agent-call.commands.v1`, durable `direct` |
| Tenant command queue | `agent-call.executor.{tenant_key}.v1`, durable, one exact binding |
| Command routing key | `agent-call.tenant.{tenant_key}.call.execute` |
| Event exchange | `agent-call.events.v1`, durable `topic` |
| SaaS result queue | `agent-call.saas.events.v1`, durable, binding `agent-call.#` |
| Event routing key | `agent-call.{event_type}` |
| Body limit | `262144` UTF-8 bytes in the Mock profile |
| Tenant route budget | Broker limit `255` bytes; fixed prefix/suffix consume `31`, leaving `224` UTF-8 bytes |
## Delivery rules
1. SaaS persists the command publication record before publishing. A mandatory
publisher confirmation is required; an unroutable/full queue leaves the
original record retained for bounded retry.
2. The executor consumes only its trusted tenant queue. RabbitMQ messages are
acknowledged after durable SQLite acceptance or durable dead-lettering, not
when they are fetched.
3. Executor business events are written to the same database transaction as
the state transition. The outbox dispatcher publishes them durably and the
SaaS inbox applies each `event_id` once. `saas_applied` may remain unknown
after broker confirmation; it does not trigger unbounded republishing.
4. `tenant_key` is copied byte-for-byte into the body, queue name, binding and
routing key. It is not normalized, encoded, truncated or cleaned. A route
over the byte budget is retained and not sent.
5. Replay publishes the original event body and original `event_id` from a
fixed retention cutoff. It never creates a new business fact and never
includes events written after that cutoff.
6. HTTP has no call execution or redial endpoint. Control, query, replay and
recording metadata paths require bearer scope and tenant scope.
The in-process broker is only for deterministic tests. Docker Compose uses the
same topology through the `pika` adapter and RabbitMQ durable queues.
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://agent-call.invalid/contracts/mq.schema.json",
"title": "agent-call MQ command and event envelope",
"oneOf": [{"$ref": "#/$defs/executeCommand"}, {"$ref": "#/$defs/event"}],
"$defs": {
"id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\s/\\\\]+$"},
"tenantKey": {"type": "string", "minLength": 1},
"time": {"type": "string", "format": "date-time"},
"executeCommand": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "command_type", "command_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after", "payload"],
"properties": {
"schema_version": {"const": "1.0"}, "command_type": {"const": "call.execute"}, "command_id": {"$ref": "#/$defs/id"},
"tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"},
"issued_at": {"$ref": "#/$defs/time"}, "not_after": {"$ref": "#/$defs/time"}, "payload": {"$ref": "#/$defs/executePayload"}
}
},
"executePayload": {
"type": "object", "additionalProperties": false,
"required": ["execution_id", "task_id", "task_item_id", "task_revision", "callee", "route_policy_id", "caller_profile_id", "agent_version_id", "variables", "ring_timeout_ms", "max_call_duration_ms"],
"properties": {
"execution_id": {"$ref": "#/$defs/id"}, "task_id": {"$ref": "#/$defs/id"}, "task_item_id": {"$ref": "#/$defs/id"},
"task_revision": {"type": "integer", "minimum": 1}, "callee": {"type": "string", "minLength": 1, "maxLength": 256},
"route_policy_id": {"$ref": "#/$defs/id"}, "caller_profile_id": {"$ref": "#/$defs/id"}, "agent_version_id": {"$ref": "#/$defs/id"},
"variables": {"type": "object", "additionalProperties": true}, "ring_timeout_ms": {"type": "integer", "minimum": 1}, "max_call_duration_ms": {"type": "integer", "minimum": 1}
}
},
"event": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "event_id", "event_type", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version", "payload"],
"properties": {
"schema_version": {"const": "1.0"}, "event_id": {"$ref": "#/$defs/id"},
"event_type": {"enum": ["command.result", "call.status", "transcript.updated", "call.finished", "recording.ready", "recording.failed", "transcript.failed", "contact.opt_out"]},
"tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, "occurred_at": {"$ref": "#/$defs/time"},
"aggregate_type": {"enum": ["command", "call", "transcript_segment", "recording"]}, "aggregate_id": {"$ref": "#/$defs/id"}, "aggregate_version": {"type": "integer", "minimum": 1}, "payload": {"type": "object"}
}
}
}
}
+169
View File
@@ -0,0 +1,169 @@
# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema
openapi: 3.1.0
info:
title: agent-call SaaS Recording Handoff API
version: 1.0.0
description: >-
Internal storage handshake. Business results still return through RabbitMQ.
servers:
- url: https://saas.internal
security:
- bearerAuth: []
paths:
/internal/v1/outbound/recording-uploads:
post:
operationId: createRecordingUpload
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UploadRequest'}
responses:
'201':
description: Upload session created or existing session returned
headers: {Cache-Control: {schema: {const: no-store}}}
content:
application/json:
schema: {$ref: '#/components/schemas/UploadSession'}
'200':
description: Existing upload session
content:
application/json:
schema: {$ref: '#/components/schemas/UploadSession'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/internal/v1/outbound/recording-uploads/{upload_id}/complete:
post:
operationId: completeRecordingUpload
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/RequestId'
- $ref: '#/components/parameters/IdempotencyKey'
- name: upload_id
in: path
required: true
schema: {$ref: '#/components/schemas/Id'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CompleteRequest'}
responses:
'200':
description: Object independently verified
content:
application/json:
schema: {$ref: '#/components/schemas/VerifiedUpload'}
'409': {$ref: '#/components/responses/Conflict'}
'410': {$ref: '#/components/responses/Expired'}
'422': {$ref: '#/components/responses/Unprocessable'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer}
parameters:
TenantId:
name: X-Tenant-ID
in: header
required: true
schema: {type: string}
RequestId:
name: X-Request-ID
in: header
required: true
schema: {type: string}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string}
schemas:
Id:
type: string
minLength: 1
maxLength: 128
pattern: '^[^\\s/\\\\]+$'
UploadRequest:
type: object
additionalProperties: false
required:
- recording_id
- call_id
- content_type
- size_bytes
- checksum_algorithm
- checksum
- channels
- sample_rate_hz
- duration_ms
properties:
recording_id: {$ref: '#/components/schemas/Id'}
call_id: {$ref: '#/components/schemas/Id'}
content_type: {type: string, const: audio/wav}
size_bytes: {type: integer, minimum: 1}
checksum_algorithm: {type: string, const: SHA-256}
checksum: {type: string, pattern: '^[0-9a-f]{64}$'}
channels: {type: integer, const: 1}
sample_rate_hz: {type: integer, minimum: 8000}
duration_ms: {type: integer, minimum: 1}
UploadSession:
type: object
required:
- upload_id
- recording_id
- expires_at
- upload_method
- upload_url
- required_headers
- constraints
properties:
upload_id: {$ref: '#/components/schemas/Id'}
recording_id: {$ref: '#/components/schemas/Id'}
expires_at: {type: string, format: date-time}
upload_method: {const: PUT}
upload_url: {type: string, format: uri}
required_headers: {type: object}
constraints: {type: object}
oss_id: {type: [string, 'null']}
CompleteRequest:
type: object
additionalProperties: false
required: [recording_id, size_bytes, checksum_algorithm, checksum]
properties:
recording_id: {$ref: '#/components/schemas/Id'}
size_bytes: {type: integer, minimum: 1}
checksum_algorithm: {const: SHA-256}
checksum: {type: string, pattern: '^[0-9a-f]{64}$'}
etag: {type: [string, 'null']}
VerifiedUpload:
type: object
required: [upload_id, recording_id, status, oss_id, verified_at]
properties:
upload_id: {$ref: '#/components/schemas/Id'}
recording_id: {$ref: '#/components/schemas/Id'}
status: {const: verified}
oss_id: {type: string}
verified_at: {type: string, format: date-time}
Problem:
type: object
required: [type, title, status, code, detail, request_id, retryable]
properties:
type: {type: string}
title: {type: string}
status: {type: integer}
code: {type: string}
detail: {type: string}
request_id: {type: string}
retryable: {type: boolean}
responses:
Unauthorized: {description: Unauthorized}
Forbidden: {description: Forbidden}
Conflict: {description: Idempotency conflict}
Expired: {description: Upload expired}
Unprocessable: {description: Object failed independent verification}
@@ -0,0 +1 @@
acceptance_gate_exit=1
@@ -0,0 +1,121 @@
{
"finished_at": "2026-09-11T16:07:58.537Z",
"gate_status": "INCOMPLETE",
"probes": {
"V01": {
"duration_ms": 11.0,
"evidence": {
"http_execute_route": "absent",
"profile": "mock",
"schema_negative_cases": 3
},
"status": "PASS"
},
"V02": {
"duration_ms": 95.403,
"evidence": {
"calls": 1,
"different_execution_command": "associated",
"same_command_replays": 1
},
"status": "PASS"
},
"V03": {
"duration_ms": 204.088,
"evidence": {
"lease_duplicate_calls": 0,
"scheduler_instances": 2,
"tenants_admitted": [
"tenant-demo",
"tenant-b",
"tenant-c"
]
},
"status": "PASS"
},
"V04": {
"duration_ms": 28.609,
"evidence": {
"affected_commands": 1,
"stale_cas": "rejected",
"waiting_barrier": "applied"
},
"status": "PASS"
},
"V05": {
"duration_ms": 83.973,
"evidence": {
"dead_letters": 0,
"recovered": true,
"retained_id": "cmd_broker_retry"
},
"status": "PASS"
},
"V06/V07": {
"duration_ms": 2077.321,
"evidence": {
"asterisk_real": "not_run",
"fallback_redials": 0,
"mock_protocol": "pass",
"outcomes": {
"busy": "busy",
"disconnect": "unknown",
"no_answer": "no_answer"
}
},
"status": "PASS"
},
"V08": {
"duration_ms": 668.155,
"evidence": {
"old_tts_reused": false,
"segments": 200,
"turns": 100
},
"status": "PASS"
},
"V09": {
"duration_ms": 1087.237,
"evidence": {
"bad_checksum": "failed",
"invalid_ready": 0,
"missing_object": "failed"
},
"status": "PASS"
},
"V10": {
"duration_ms": 82.427,
"evidence": {
"original_event_ids": true,
"pending_after_recovery": 0,
"pending_during_outage": 13
},
"status": "PASS"
},
"V11/V12": {
"duration_ms": 88.53,
"evidence": {
"backup": "verified",
"metrics": "healthy",
"real_mode_mock_rejection": true,
"rollback_preserves_db": true
},
"status": "PASS"
},
"V13": {
"duration_ms": 0.003,
"evidence": {
"reason": "requires dedicated SCALE-MOCK host and sustained media load",
"required_hold_minutes": 60,
"simulated_calls_declared": 1200,
"status": "not_run"
},
"status": "NOT_RUN"
}
},
"production": {
"V14": "blocked_external_evidence"
},
"profile": "mock",
"started_at": "2026-09-11T16:07:54.111Z"
}
@@ -0,0 +1,124 @@
{
"finished_at": "2026-09-12T03:58:28.889Z",
"gate_status": "INCOMPLETE",
"probes": {
"V01": {
"duration_ms": 11.378,
"evidence": {
"http_execute_route": "absent",
"profile": "mock",
"schema_negative_cases": 3
},
"status": "PASS"
},
"V02": {
"duration_ms": 103.751,
"evidence": {
"calls": 1,
"different_execution_command": "associated",
"same_command_replays": 1
},
"status": "PASS"
},
"V03": {
"duration_ms": 228.406,
"evidence": {
"lease_duplicate_calls": 0,
"scheduler_instances": 2,
"tenants_admitted": [
"tenant-demo",
"tenant-b",
"tenant-c"
]
},
"status": "PASS"
},
"V04": {
"duration_ms": 27.291,
"evidence": {
"affected_commands": 1,
"stale_cas": "rejected",
"waiting_barrier": "applied"
},
"status": "PASS"
},
"V05": {
"duration_ms": 90.358,
"evidence": {
"dead_letters": 0,
"recovered": true,
"retained_id": "cmd_broker_retry"
},
"status": "PASS"
},
"V06/V07": {
"duration_ms": 2079.879,
"evidence": {
"asterisk_real": "not_run",
"fallback_redials": 0,
"mock_protocol": "pass",
"outcomes": {
"busy": "busy",
"disconnect": "unknown",
"no_answer": "no_answer"
}
},
"status": "PASS"
},
"V08": {
"duration_ms": 823.004,
"evidence": {
"old_tts_reused": false,
"segments": 200,
"turns": 100
},
"status": "PASS"
},
"V09": {
"duration_ms": 1092.352,
"evidence": {
"bad_checksum": "failed",
"invalid_ready": 0,
"missing_object": "failed"
},
"status": "PASS"
},
"V10": {
"duration_ms": 78.156,
"evidence": {
"original_event_ids": true,
"pending_after_recovery": 0,
"pending_during_outage": 13
},
"status": "PASS"
},
"V11/V12": {
"duration_ms": 76.478,
"evidence": {
"backup": "verified",
"metrics": "healthy",
"real_mode_mock_rejection": true,
"rollback_preserves_db": true
},
"status": "PASS"
},
"V13": {
"duration_ms": 0.507,
"evidence": {
"evidence_file": "docs/evidence/scale-mock-2026-09-12.json",
"minimum_answered_calls": 1004,
"observed_hold_seconds": 3600.0,
"production_claim": false,
"status": "observed",
"submitted_calls": 1200,
"tenants": 100
},
"status": "PASS"
}
},
"production": {
"V14": "blocked_external_evidence"
},
"profile": "mock",
"started_at": "2026-09-12T03:58:24.277Z"
}
+27
View File
@@ -0,0 +1,27 @@
# R1 Asterisk/SIP 真实联调证据(2026-09-12)
## 结果
- 新建北京竞价 ECS:`i-2zed29evhasbqzag63mg`,已按测试清理删除。
- 固定 EIP:`123.56.71.98`,未释放、未手工解绑;清理后状态为 `Available`。
- Asterisk:`22.10.1`,镜像使用固定 digest:`sha256:0f5e3cd0e9a86bc9dc4750929abee9ba1faae11ed39739f3e75343d39d837e41`。
- 线路:UDP `61.132.228.221:5060`,IP 白名单免注册。
- PJSIP contact:`Avail`,实测 RTT 约 `22–25 ms`。
- Asterisk 对外信令/媒体地址均为 `123.56.71.98`;UDP 5060、RTP 10000–10800 已配置。
- 已按确认规则使用原值主叫 `BD93205882`:`from_user` 与 `CALLERID` 均设置,未发送 P-Asserted-Identity。
## 单次真实外呼探针
- 原始被叫:`18601013734`。
- 按线路规则构造:`7089` + 原始被叫 = `708918601013734`,未重复加前缀。
- INVITE 已从 `123.56.71.98:5060` 发到供应商并收到 `100 trying`,说明公网出口、SIP 路由和白名单链路可达。
- 供应商随后返回:`488 Not Acceptable Here`,`Reason: Q.850;cause=88;text="INCOMPATIBLE_DESTINATION"`。
- 未收到 `180/183/200`,未建立通话或 RTP;未自动重试。
## 清理与遗留
- 测试 ECS 已停止并删除;未操作无关实例。
- 专用安全组已删除;此前仅含供应商 `/32` 的 SIP/RTP 规则,临时 SSH `/32` 规则已先撤销。
- 专用 SSH KeyPair 已删除,本地受限状态/密钥文件已清理,未写入代码、文档或日志。
- 当前阻塞是供应商对目标号码/线路规则返回 `INCOMPATIBLE_DESTINATION`;需供应商确认被叫格式/路由、主叫域及编解码要求后才能再次发起测试。
- 本次未验证真实 MQ、ASR/LLM/TTS、OSS 或完整 AI 通话。
@@ -0,0 +1,16 @@
# Aliyun Beijing read-only inventory — 2026-09-11
Commands executed without create/associate/delete actions:
```text
aliyun ecs DescribeInstances --RegionId cn-beijing --PageSize 100
aliyun vpc DescribeEipAddresses --RegionId cn-beijing --PageSize 100
```
Observed facts:
- Region `cn-beijing` has one existing ECS instance, `ecs.e-c1m1.large`, status `Running`, charge type `PrePaid`, with public IP `39.105.111.158`. It is not reused or modified because it is not an authorized target spot test cell.
- The registered SIP egress EIP `123.56.71.98` exists in Beijing, allocation `eip-2zeevfsaxzwuue2szy7xb`, status `Available`, and is currently unassociated.
- No instance/EIP was created, rebound, stopped, deleted, or released.
Creation remains gated on explicit test budget/spot price ceiling, disk/VSwitch/security-group selection, SSH key authorization, and the real SIP/AI/OSS acceptance window. The available EIP must be associated only through the approved cell plan; it must never be placed in a SIP trunk contact field or replaced with a newly allocated address.
+1
View File
@@ -0,0 +1 @@
mock profile clean
@@ -0,0 +1,5 @@
ready= ready
command_status= completed
command_reason= CALL_COMPLETED
rabbit_provider= agent_call_provider_mode_info{component="rabbitmq",mode="rabbit"} 1
outbox_pending= agent_call_outbox_pending 0
+65
View File
@@ -0,0 +1,65 @@
{
"acceptance": "V13",
"profile": "mock",
"status": "observed",
"run_started_at": "2026-09-12T02:50:20Z",
"run_finished_at_observed": "2026-09-12T03:51:31Z",
"host": {
"region": "cn-beijing",
"zone": "cn-beijing-g",
"instance_id": "i-2zeivenvp67lj4es3l62",
"instance_type": "ecs.hfg6.large",
"fixed_egress_ip": "123.56.71.98",
"image_id": "rockylinux_9_8_x64_20G_alibase_20260827.vhd",
"system_disk": {
"category": "cloud_essd",
"size_gib": 40,
"performance_level": "PL1"
},
"spot_price_limit_cny_per_hour": 0.15
},
"load": {
"tenants": 100,
"submitted_calls": 1200,
"one_cell_disabled": true,
"required_hold_minutes": 60,
"threshold_reached_seconds": 51.479,
"observed_hold_seconds": 3600.0,
"answered_calls_at_end_before_teardown": 1200,
"minimum_answered_calls": 1004
},
"teardown": {
"stop_commands_requested": 100,
"stop_commands_applied": 100,
"active_calls_after_cleanup": 0,
"nonterminal_commands_after_cleanup": 0,
"outbox_pending_after_cleanup": 0,
"clean": true
},
"remote_evidence": {
"result_sha256": "a5faf7adccfb77151e2fcc20efbd53fbdede7bf77354685fb55b641148de9db7",
"sqlite_sha256": "3fdc0461b7da8c0f14b6b0f186af3654082c7512d0991373dc7bf6f37b82ff8b",
"source_hashes": {
"scripts/scale_mock.py": "5b10afb2a741b440f3a73688cd5632c1c4890ff760629e1fbbce6dcc89073087",
"agent_call/core.py": "84f7085bfdecb337229c5415687c852fc3e639c6bbf08be2886f6b1ead50f590",
"docs/contracts/mq.schema.json": "4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a"
},
"call_states_after_teardown": {
"failed": 1200
},
"command_states_after_teardown": {
"applied": 100,
"failed": 1200
},
"event_counts": {
"call.finished": 1200,
"call.status": 4800,
"command.result": 3830
}
},
"limitations": [
"This is SCALE-MOCK protocol/load evidence only; continuous_media_evidence is false.",
"It does not verify real RTP, Asterisk, SIP, ASR, LLM, TTS, OSS, SaaS, production SLA, or N+1 capacity."
],
"production_claim": false
}
@@ -0,0 +1,10 @@
{
"calls": 1200,
"calls_per_tenant": 12,
"cells": 101,
"reason": "pass --run only on a dedicated SCALE-MOCK host",
"required_hold_minutes": 60,
"safe_capacity_after_one_cell_loss": 1200,
"status": "not_run",
"tenants": 100
}
@@ -50,7 +50,7 @@ OpenAPI 只描述 HTTP 请求/响应;MQ 用 JSON Schema 和真实 AMQP 语义
### 3.2 契约交付与测试约束
以下是 **D01 计划产物,当前未生成**;目录统一放在 docs/contracts/,不在本轮创建空文件占位:
以下是 **D01 已落地产物**;目录统一放在 docs/contracts/,由契约测试和 Mock 验收持续校验:
- `executor.openapi.yaml`:呼出应用提供的控制、查询、两类补传;`saas.openapi.yaml`:SaaS 提供的上传授权/完成确认。使用 OpenAPI 3.1,固定幂等、鉴权、错误和版本语义。
- `mq.schema.json`:执行、事件外壳及分类型 payload;集中样例和正反例复用同一 Schema。标准 HTTP 响应/事件容许兼容的可选扩展,未知主版本隔离;拒绝非法命令字段,不把严格校验和任意扩展混为一谈。
@@ -289,7 +289,7 @@ M3产物 + C1~C6真实配置/权限(受控存放)+ R1/R2全部证据 + 实
## 10. 本次文档验收与后续入口
本次仅检查文档引用、门禁一致性、既有样例完整性和Markdown;当前所有M/R运行用例仍为待执行。当前已存在的回归命令可继续使用,但不能把它们当作新平台全部测试:
本轮已完成文档引用、契约/实现一致性、样例完整性、Mock开发回归及V01~V13验证;V14/R1/R2/R3仍因真实外部证据待执行。当前回归命令可继续使用,但不能把Mock结果当作生产验收:
```bash
python3 -m unittest discover -s tests -v
@@ -298,4 +298,12 @@ node --test tests/test_pcm.cjs
bash -n deploy/asterisk.sh
```
下一实施入口是 D01:生成并验证机器可读契约与Mock profile,再做D02;无需再次讨论已接受的A类方案。遇到真实供应商协议/合规/资源事实不兼容,只对具体差异登记变更,不把整个计划退回重选,也不让Mock假成功掩盖差异。
下一实施入口是 D12/R1:在取得真实SaaS、SIP/Asterisk、ASR/LLM/TTS、OSS、出口、容量和运维证据后逐项替换Mock并复验;无需再次讨论已接受的A类方案。遇到真实供应商协议/合规/资源事实不兼容,只对具体差异登记变更,不把整个计划退回重选,也不让Mock假成功掩盖差异。
### 10.1 当前执行记录(2026-09-12)
已落地 D01 的 OpenAPI 3.1、共用 MQ Schema、集中样例和 Mock profile,以及 D02/D03 的 SQLite 持久状态、租户独立 RabbitMQ 拓扑、outbox/inbox、CAS 控制、重放、Mock SIP/ARI/RTP/AI/OSS 协议记录、监控/告警、迁移/备份和 Compose 部署入口。测试号码 `18601013734` 在 Mock 与明确授权的 R1 探针中均保持原值;当前线路目标按规则生成 `708918601013734`。
本轮可重复命令 `python3 -m unittest discover -s tests -v` 和 RabbitMQ Compose smoke 已通过;`python3 -m scripts.acceptance` 的 V01~V13 Mock 探针通过,因 V14 仍需外部真实证据而按门禁返回非零,证据归档于 `docs/evidence/acceptance-mock-2026-09-12.json`。SCALE-MOCK 在阿里云北京专用竞价 Cell `ecs.hfg6.large` 上完成:100 租户、1200 模拟呼叫、禁用1个Cell,最低1004路已回答并连续观察3600秒;详见 `docs/evidence/scale-mock-2026-09-12.json`。该证据仅证明 Mock 协议/持久状态负载基线,`continuous_media_evidence` 为 false,不替代真实媒体容量。
R1 已完成一次真实 Asterisk/SIP 单路探针:新建测试 ECS `i-2zed29evhasbqzag63mg` 使用固定 EIP `123.56.71.98` 和 Asterisk 22.10.1 digest,PJSIP contact/OPTIONS 可达,真实 INVITE 已送达供应商;目标呼叫收到 `488`、`Q.850 cause=88 INCOMPATIBLE_DESTINATION`,未接通、未建立 RTP,证据见 `docs/evidence/asterisk-real-2026-09-12.md`。该测试实例已停止并删除,专用安全组和KeyPair也已删除,EIP 未释放且回到 Available,临时 SSH 规则已撤销。真实接通/RTP、ASR/LLM/TTS/OSS、生产 1000 路完整 AI、N+1、实际CPS/SLO及副作用正确性仍阻塞,现有产物不得据此宣称生产完成。
+48 -14
View File
@@ -1,6 +1,6 @@
# 部署接入:本轮实现与运行说明
> 本文只说明当前已存在的ASR/部署工具。后续完整执行平台按[最终开发部署监控与验收计划](最终开发部署监控与验收计划_v1.0.md)从契约与Mock开始;未来服务、Compose和运行命令须实现后补充,不把本运行说明当作完整平台已上线。
> 本文同时说明现有 ASR/部署工具与已落地的 agent-call Mock 验证栈。完整执行平台仍按[最终开发部署监控与验收计划](最终开发部署监控与验收计划_v1.0.md)推进;Mock 通过不等于真实线路、供应商或生产容量验收。
## 1. 实施边界
@@ -12,12 +12,13 @@
| ASR 连接与协议 | 已加固:断开清理、取消、写入/启动时限、最终结果背压、火山帧长度及gzip解压上限 |
| 部署底座 | 已实现:非root/read-only ASR容器、回环端口、Asterisk配置生成和显式启动检查 |
| 阿里云主机准备 | 已实现CLI驱动的只读计划、受控创建竞价实例、复用主机和绑定既有EIP;默认不修改云资源 |
| 云端实际操作 | 未执行;当前本机无aliyun CLI/云凭据,未核实固定IP归属,未创建实例或改绑IP |
| Asterisk真实接入 | 未执行;指定SIP地址/鉴权、VPC网络、镜像digest等仍需填写 |
| 云端实际操作 | 已完成V13一次性专用竞价Cell创建、绑定固定EIP、SCALE-MOCK及回收;测试实例已删除,固定EIP保留 |
| Asterisk真实接入 | 已完成R1单路底座与SIP探针;OPTIONS/contact可达,但真实INVITE被供应商以488/Q.850 cause=88拒绝,未通过外呼验收 |
| LLM/TTS | 未实现、未启用,等待用户新的供应商/协议/参数规范 |
| ARI业务调度、自动FALLBACK、MQ/OSS回传 | 尚未实现;仍按开发计划推进,不能把两个trunk配置当成自动切换代码 |
| agent-call Mock 调度、MQ/OSS闭环 | 已实现:租户独立路由、DB状态、CAS控制、outbox/inbox、Mock SIP/ARI/RTP/AI和OSS校验;未替代真实Asterisk/供应商验收 |
| ARI业务调度、自动FALLBACK、真实MQ/OSS回传 | 未完成;现有主备trunk配置不能当成自动切换代码 |
当前目标是一台北京竞价ECS上的部署底座。ASR测试台与SIP媒体尚未连通;浏览器识别不是电话外呼,也不是已完成SaaS业务验收。
当前目标仍是一台北京竞价ECS上的部署底座。R1已验证固定EIP、UDP SIP链路和Asterisk配置;真实通话路由仍被供应商拒绝。ASR测试台与电话媒体尚未连通;浏览器识别不是电话外呼,也不是已完成SaaS业务验收。
参考:`sip-research@6a8064e53bb7eadd73f03373524953e68330976c`;ASR协议源自`voice_test@6772bf4`,仅导入`asr.go/asr_bailian.go/asr_volc.go`后进行加固,不导入原main、配置页、LLM或TTS代码。
@@ -166,7 +167,7 @@ python3 deploy/render_asterisk.py --config .local/asterisk.json
### 5.2 镜像与安全组
`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。参考仓库使用latest、历史记录22.10.1,不代表该镜像当前版本已经被本项目验证;本轮没有拉取或启动真实Asterisk镜像。
`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。本轮R1使用内部Registry中的固定digest镜像并实测Asterisk 22.10.1;该镜像验证仅覆盖单路底座,不等于生产镜像批准或容量验收。配置读取权限按镜像实际UID/GID修正为必要的组读权限,未使用chmod 777。
确认:SIP服务端IP/协议/端口、RTP回程、EIP/NAT、实际VPC网段、管理来源、录音卷目录和权限。安全组只按来源和用途开放;不公开裸ARI,不清空既有防火墙。
@@ -200,16 +201,49 @@ bash -n deploy/asterisk.sh
- 自动测试:云只读/归属/固定IP、创建幂等、失败恢复、EIP绑定竞态、库存完整性;Asterisk配置验证;ASR鉴权、取消、模拟WebSocket结果、帧边界及PCM编码。
- 本地容器:ASR镜像构建、非root/read-only运行、回环HTTP与healthcheck;测试容器已清理。
- 浏览器:无效令牌拒绝、正常令牌读取未配置模型、模型禁用、LLM/TTS未启用。没有采集真实麦克风,也没有发送真实供应商请求。
- 未执行:真实阿里云查询/创建/绑定、SIP通话、真实ASR、LLM/TTS、MQ/OSS、完整外呼和竞价回收恢复。
- 已执行:阿里云只读盘点、一次V13专用竞价Cell创建/绑定、Mock规模运行、一次R1真实SIP/Asterisk探针、测试实例停止/删除和固定EIP保留验证。
- 未执行或未通过:真实外呼接通/RTP、真实ASR、LLM/TTS、MQ/OSS、完整外呼以及生产容量/N+1验收;R1证据见`docs/evidence/asterisk-real-2026-09-12.md`。
本地模拟通过不等于生产可用。只有填齐资源和契约、执行既有验收文档相应场景并留存证据后,才可把阶段状态更新为真实环境验收通过。
## 7. 下一步需要用户提供/确认
## 7. agent-call Mock 验证栈
1. 在本机通过安全方式配置阿里云CLI与Profile/RAM Role/STS,并确认123.56.71.98的实际归属及是否可迁移EIP。
2. 北京实例规格、镜像、VSwitch、安全组、SSH KeyPair、竞价上限及磁盘/EIP/流量预算;现有主机是否允许复用。
3. SIP主备真实地址、协议、鉴权、号码/主叫要求和接入限制。
4. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。
5. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。
### 7.1 本地启动
以上未确认前,不创建计费资源、不改绑白名单IP、不自动拨真实号码,也不声称完成整个平台。
```bash
# 仅使用本地SQLite和严格的进程内Broker,适合单元/验收探针。
python3 -m agent_call
# RabbitMQ模式需要先注入受控环境变量;示例只含合成值。
set -a; . deploy/mock.env.example; set +a
# 按实际密码/URL替换示例值后启动:
docker compose up -d --build rabbitmq agent-call
curl --fail http://127.0.0.1:18080/healthz/ready
```
业务执行命令只能经RabbitMQ租户队列进入;HTTP只提供控制、查询、重放和录音交接路径。测试被叫保留原值`18601013734`,Mock线路生成目标`708918601013734`,主叫显示保留`BD93205882`。
### 7.2 可重复检查
```bash
python3 -m unittest discover -s tests -v
python3 -m scripts.acceptance
python3 deploy/check_mock_config.py
python3 deploy/migrate.py data/agent-call.sqlite3
```
`agent_call/core.py`使用真实SQLite表保存任务、执行、通话、尝试、事件、outbox/inbox、上传和重放状态;`deploy/backup_sqlite.py backup|restore`提供离线备份恢复。`ProtocolJournal`只记录合成的SIP/ARI/RTP/AI协议事实,不把它们伪装成真实供应商响应。
### 7.3 当前门禁
本地V01-V12及阿里云专用主机上的V13 Mock探针已通过;完整验收脚本仍因V14需要外部真实证据而按门禁返回非零,证据见`docs/evidence/acceptance-mock-2026-09-12.json`和`docs/evidence/scale-mock-2026-09-12.json`。V13使用100租户/1200模拟接通、禁用1个Cell,最低1004路已回答并持续观察3600秒;这是Mock协议/持久状态负载证据,不是RTP或生产容量证据。R1真实SIP证据见`docs/evidence/asterisk-real-2026-09-12.md`:固定EIP和SIP OPTIONS/INVITE链路可达,但目标呼叫收到488/Q.850 cause=88。测试ECS及本次专用安全组/KeyPair已回收,固定EIP`123.56.71.98`保持Available。真实接通/RTP、ASR/LLM/TTS/OSS、生产1000路/N+1尚未完成。正式模式拒绝当前Mock profile,不能把测试号码或Mock成功自动升级为真实外呼授权。
## 8. 下一步需要用户提供/确认
1. 供应商需确认`7089`前缀/目标号码路由、From域(当前实际出现私网域)及允许的编解码;R1证据中的`488/Q.850 cause=88 INCOMPATIBLE_DESTINATION`未通过外呼验收。
2. 确认后再按单次授权重新创建测试Cell并只发起一通探针;不沿用旧测试实例、不自动重试、不改绑或释放固定EIP。
3. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。
4. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。
5. 真实接通后仍需分别完成RTP、录音、MQ/OSS、ASR/LLM/TTS和生产1000路/N+1验收。
在供应商规则确认前,不再次拨打真实号码,也不声称完成整个平台。