diagnose native call phase without exposing provider responses

This commit is contained in:
2026-10-04 17:57:04 +08:00
parent c45a05756d
commit 9b9f9f0e05
5 changed files with 83 additions and 20 deletions
+1 -1
View File
@@ -80,7 +80,7 @@
- P01–P08 及 K01–K16 已完成**项目内隔离 Mock** 核验;本轮把分散的人类可读契约、文档与第三方对接合为唯一当前规范,不重审已确认规则。若未来另获启动开发/审查子 Agent 授权,必须按使用者指定的 `gpt-5.6-luna`、`max` 思考和 `fast: true` 逐项核验并显式配置,不静默换模型、降档或关闭 fast。
- 唯一现行 SaaS↔Dispatcher 业务规范是 [`docs/thirds/saas-dispatcher.md`](docs/thirds/saas-dispatcher.md);当前项目内 Schema、拓扑、正反例及来源/hash 在 [`contracts/local/`](contracts/local/);内部 Agent RPC 在 [`proto/agent/agent.proto`](proto/agent/agent.proto)。本地验收与外部缺口见 [`docs/evidence/saas-dispatcher-p08-acceptance.md`](docs/evidence/saas-dispatcher-p08-acceptance.md)。Markdown 不代替机器合同或外部签收,也不另外维护一份平行字段定义。
- 已由当前合同来源清单固定哈希的历史提案与计划保留**原字节**于 [`docs/archive/sources/`](docs/archive/sources/README.md),使用者原有未提交的两份旧对接文档也按原字节归档;旧上游 v1 在 [`docs/archive/upstream/`](docs/archive/upstream/README.md) 可离线校验,但不嵌入运行合同。旧 F/W 工作包、旧 MQ-only 合同及归档不作为当前运行入口。固定 MQ `v1`、HTTP `/internal/v1/dispatcher/...` 和业务 revision 是现行通信规则,不是自有实现代次;不得为历史路径新建兼容或回退。
- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需完整非生产证据/正式获批指令的 `nonprod-real` 为彼此独立的入口。新增非生产真实入口尚未在测试机完成全链路核验或拨号;不得以编译/本机 Mock 通过宣称可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,无拨号,不证明线路可用或生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。
- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需完整非生产证据/正式获批指令的 `nonprod-real` 为彼此独立的入口。新增非生产真实入口尚未在测试机完成全链路核验;2026-10-04 获批的同一数企/号码两次单次试拨操作均未观测到 SIP 包或最终结果,第二次已有 Dispatcher 派发回执而 Agent 错误根因未知。未交付回执保留,Dispatcher/Agent 已停机;不得自动重拨、清理未知执行或以编译/本机 Mock 通过宣称可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,至今没有观察到真实 SIP 拨号,不证明线路可用或生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。
- 使用者批准独立的测试专用 `deploys/test/saas-mock/` 仅模拟缺失的 SaaS:从 0600 的静态快照提供五类正式 HTTP 配置,在专用 RabbitMQ vhost 中由模拟 SaaS 预建现行拓扑;不在 Dispatcher 内注入快照;默认不发布外呼消息,只有受限脚本已为同一 `event_id`/线路/原始号码启动抓包后,才可显式单次 MQ 投递;不替代 Agent/Asterisk/AI/OSS。测试用正式入口必须同时具备只读配置、专用 MQ、真实 Agent/Asterisk/AI/录音/OSS、逐通确认和拨号前活跃抓包证据,缺一项不得试拨。此模拟不构成真实 SaaS 签收。
- 开发按 TDD 分批,小步提交;不得覆盖使用者现存修改/未跟踪文件,不自动清理、迁移或覆盖任何现存 SQLite、spool、outbox 和 Agent 恢复文件。旧 `.executions` 及恢复根目录中旧 `.uploads`、`.upload-locks`、逐执行 `state.json` 的发现须只读失败关闭,现存未交付事实由使用者确认处置。真实云账号、EIP、线路、拨号、生产部署和共享数据操作分别需要明确授权。
+1 -1
View File
@@ -75,7 +75,7 @@ func newRealAgentServer(ctx context.Context, settings config.AgentEnvironment, d
}).Prepare(execution)
},
OnFailure: func(execution rpc.ApprovedExecution, cause error) error {
log.Printf("Agent real call requires inspection: event_id=%q task_id=%q cause_type=%T", execution.SourceEventID, execution.TaskID, cause)
log.Printf("Agent real call requires inspection: event_id=%q task_id=%q native_phase=%q ari_http_status=%d cause_type=%T", execution.SourceEventID, execution.TaskID, asterisk.NativeCallPhase(cause), asterisk.NativeCallHTTPStatus(cause), cause)
return nil
},
}
@@ -0,0 +1,14 @@
# 2026-10-04 数企非生产单次操作记录
范围:仅 `trunk-shuqi` → 原始号码 `15003164745`;两次均分别获使用者确认,**没有授权自动补拨或其他组合**。证据原件仅保存在测试机受限目录 `/var/lib/sip-go-agent/evidence/<event_id>/`,不复制到仓库。生产签收:`production_approval=false`。
| 次数 | 事件号 | 事实 | 结果 |
| --- | --- | --- | --- |
| 1 | `call-0eeb0f6d-2ca9-4789-9b88-509add215c70` | 抓包已启动;私有投递封装器未导出 RabbitMQ 环境变量,MQ 发布前退出;无 SIP 包、无 Dispatcher inbox/outbox、无最终结果。封装器已修复,但没有重试同一事件。 | 失败,保留台账及原始证据。 |
| 2 | `call-669f8829-a111-476d-88c7-3de604bf6dc6` | Dispatcher inbox 已接纳、Agent 接到执行;共享 SaaS 队列收到本次 `call.execute` 的 `dispatched` 派发回执,**不是** `call.execute.result`。旧版 SaaS 等待器将回执误判为最终结果归属不符并提前退出;Agent 同时记录执行失败,旧日志只有 `*errors.joinError`,无法查明底层错误。抓包 0 包,无 SIP INVITE 或最终结果,Asterisk 无活动通道。 | 失败;不得声称已拨通、取得真实 AI/录音/OSS 结果。 |
第二次抓包文件 SHA-256:`e3f42e2687636327d7f18c9635173252505b4a838fa6829a755bab06c9c69749`(仅 24 字节空 pcap);执行状态文件 SHA-256:`ced1dfa2a7d92313563069504e78156ad106297ccd6676c023a50215d7dceaa9`。Asterisk 日志有 WebSocket Origin/CORS 提示,但后续只读 ARI 连通检查成功,**不能据此认定它是 Agent 失败根因**。
现状:专用 Dispatcher、Agent 均已停止;Asterisk 无活动通话;SaaS 结果队列 `agent-call.saas.events.v1` 留存 **1 条派发回执**、无消费者。Dispatcher 的 inbox/outbox 和原始抓包均保留,未清理或伪造最终结果。未交付回执及未知执行的处置须经使用者确认,不因服务重启自动释放占用。
本地修改尚未部署到测试机:SaaS 等待器会先持久化并确认归属正确的派发回执,继续等待真正最终结果;Agent 对原生呼叫失败记录阶段及 ARI HTTP 状态码,不记录响应正文、凭据或音频。此修改解决**回执误判**并增强下次定位能力,**尚不能解释第二次 Agent 失败的真实原因**。任何新的真实操作必须使用新事件号、新确认、有效快照和拨号前完整抓包门禁,不复用上述任一事件。
+48 -15
View File
@@ -13,6 +13,7 @@ import (
"git.ipao.vip/rogee/go-sip/internal/media"
"github.com/CyCoreSystems/ari/v5"
"github.com/CyCoreSystems/ari/v5/client/native"
)
// NativeDial contains only the Dispatcher-selected SIP identity and the
@@ -23,6 +24,38 @@ type NativeDial struct {
MediaPayloadType uint8
}
// NativeCallFailure records a secret-free stage for a possibly ambiguous ARI
// attempt. The wrapped cause remains available for programmatic inspection.
type NativeCallFailure struct {
Phase string
Cause error
}
func (e *NativeCallFailure) Error() string {
if errors.Is(e.Cause, context.DeadlineExceeded) {
return fmt.Sprintf("native ARI %s deadline: %T", e.Phase, e.Cause)
}
return fmt.Sprintf("native ARI %s outcome unknown: %T", e.Phase, e.Cause)
}
func (e *NativeCallFailure) Unwrap() error { return e.Cause }
func NativeCallPhase(err error) string {
var failure *NativeCallFailure
if errors.As(err, &failure) {
return failure.Phase
}
return "unclassified"
}
// NativeCallHTTPStatus extracts a numeric ARI HTTP status without retaining
// a provider response body, credential or request URL in diagnostic logs.
func NativeCallHTTPStatus(err error) int {
var response native.RequestError
if errors.As(err, &response) {
return response.Code()
}
return 0
}
// NativeCall owns one real ARI channel, bridge, ExternalMedia channel and RTP
// socket; closing it cannot originate or replay a second call.
type NativeCall struct {
@@ -44,11 +77,11 @@ type NativeCall struct {
// already persisted the signed instruction and passed the host capture gate.
func (l Loader) Originate(ctx context.Context, request NativeDial) (*NativeCall, error) {
if _, err := approvedOriginateRequest(request.ExecutionID, request.TrunkID, request.DialedCallee, request.CallerID, request.AnswerTimeout); err != nil {
return nil, err
return nil, &NativeCallFailure{Phase: "validate", Cause: err}
}
client, err := l.OpenARI()
if err != nil {
return nil, err
return nil, &NativeCallFailure{Phase: "ari_open", Cause: err}
}
return dialWithClient(ctx, client, request)
}
@@ -66,38 +99,38 @@ func dialWithClient(ctx context.Context, client ari.Client, request NativeDial)
call.Context, call.cancel = context.WithCancelCause(ctx)
originate, err := approvedOriginateRequest(request.ExecutionID, request.TrunkID, request.DialedCallee, request.CallerID, request.AnswerTimeout)
if err != nil {
return nil, err
return nil, &NativeCallFailure{Phase: "validate", Cause: err}
}
call.Media, err = media.ListenRTPWithFormat("127.0.0.1:0", request.MediaPayloadType, media.FormatSLIN16, 16000)
if err != nil {
return nil, fmt.Errorf("prepare Agent RTP capture: %w", err)
return nil, &NativeCallFailure{Phase: "rtp_listen", Cause: err}
}
key := ari.NewKey(ari.ChannelKey, request.ExecutionID)
call.subscription = client.Bus().Subscribe(key, "StasisStart", "StasisEnd", "ChannelHangupRequest", "ChannelDestroyed")
if call.subscription == nil {
return nil, errors.New("native ARI channel subscription unavailable before origination")
return nil, &NativeCallFailure{Phase: "subscribe", Cause: errors.New("native ARI channel subscription unavailable before origination")}
}
call.outbound, err = client.Channel().Originate(key, originate)
if err != nil {
// The HTTP response can be lost after Asterisk has created the
// channel. Do not originate again: try to stop the same identity.
call.outbound = client.Channel().Get(key)
return nil, fmt.Errorf("native SIP origination outcome unknown: %T", err)
return nil, &NativeCallFailure{Phase: "originate", Cause: err}
}
answerCtx, cancel := context.WithTimeout(ctx, request.AnswerTimeout)
defer cancel()
if err := awaitStasisStart(answerCtx, call.subscription, request.ExecutionID); err != nil {
return nil, err
return nil, &NativeCallFailure{Phase: "answer_wait", Cause: err}
}
bridgeKey := ari.NewKey(ari.BridgeKey, request.ExecutionID+"-bridge")
call.bridge, err = client.Bridge().Create(bridgeKey, "mixing", "go-sip-agent")
if err != nil {
// A lost reply does not prove that the bridge was not created.
call.bridge = client.Bridge().Get(bridgeKey)
return nil, fmt.Errorf("create Agent ARI mixing bridge outcome unknown: %T", err)
return nil, &NativeCallFailure{Phase: "bridge_create", Cause: err}
}
if err := call.bridge.AddChannel(call.outbound.ID()); err != nil {
return nil, fmt.Errorf("add answered channel to ARI bridge: %T", err)
return nil, &NativeCallFailure{Phase: "bridge_add_outbound", Cause: err}
}
mediaKey := ari.NewKey(ari.ChannelKey, request.ExecutionID+"-media")
call.external, err = client.Channel().ExternalMedia(mediaKey, ari.ExternalMediaOptions{
@@ -108,25 +141,25 @@ func dialWithClient(ctx context.Context, client ari.Client, request NativeDial)
// Use the same identity to stop an ExternalMedia channel created
// before the HTTP outcome became unknown; never create another.
call.external = client.Channel().Get(mediaKey)
return nil, fmt.Errorf("create Agent ARI external media outcome unknown: %T", err)
return nil, &NativeCallFailure{Phase: "external_media_create", Cause: err}
}
address, err := call.external.GetVariable("UNICASTRTP_LOCAL_ADDRESS")
if err != nil {
return nil, fmt.Errorf("read Agent ARI RTP peer address: %T", err)
return nil, &NativeCallFailure{Phase: "rtp_peer_address", Cause: err}
}
port, err := call.external.GetVariable("UNICASTRTP_LOCAL_PORT")
if err != nil {
return nil, fmt.Errorf("read Agent ARI RTP peer port: %T", err)
return nil, &NativeCallFailure{Phase: "rtp_peer_port", Cause: err}
}
peer, err := netip.ParseAddr(address)
if err != nil || !peer.IsLoopback() {
return nil, errors.New("Agent ARI RTP peer is not a local Cell address")
return nil, &NativeCallFailure{Phase: "rtp_peer_validation", Cause: errors.New("Agent ARI RTP peer is not a local Cell address")}
}
if err := call.Media.SetPeer(net.JoinHostPort(address, port)); err != nil {
return nil, fmt.Errorf("configure Agent RTP peer: %w", err)
return nil, &NativeCallFailure{Phase: "rtp_peer_config", Cause: err}
}
if err := call.bridge.AddChannel(call.external.ID()); err != nil {
return nil, fmt.Errorf("add Agent RTP channel to ARI bridge: %T", err)
return nil, &NativeCallFailure{Phase: "bridge_add_media", Cause: err}
}
call.monitorDone = make(chan struct{})
go call.watch(request.ExecutionID)
+19 -3
View File
@@ -3,6 +3,7 @@ package asterisk
import (
"context"
"errors"
"fmt"
"net"
"strings"
"testing"
@@ -96,6 +97,21 @@ func (b *testBridges) AddChannel(_ *ari.Key, channelID string) error {
}
func (b *testBridges) Delete(_ *ari.Key) error { b.deleted++; return nil }
type nativeHTTPStatusError struct{ code int }
func (e nativeHTTPStatusError) Error() string { return "private ARI response" }
func (e nativeHTTPStatusError) Code() int { return e.code }
func TestNativeCallFailureReportsStageAndHTTPStatusWithoutResponseBody(t *testing.T) {
cause := fmt.Errorf("request failed: %w", nativeHTTPStatusError{code: 404})
err := errors.Join(&NativeCallFailure{Phase: "originate", Cause: cause}, errors.New("cleanup failed"))
if NativeCallPhase(err) != "originate" || NativeCallHTTPStatus(err) != 404 {
t.Fatalf("must expose classified cause without duplicating call: %v", err)
}
if strings.Contains((&NativeCallFailure{Phase: "originate", Cause: errors.New("private-credential")}).Error(), "private-credential") {
t.Fatal("diagnostic must not leak native ARI response body")
}
}
func TestNativeCallUsesOneARIOriginateAndActualRTPBridge(t *testing.T) {
events := make(chan ari.Event, 2)
client := &testARIClient{
@@ -164,7 +180,7 @@ func TestNativeCallUnknownOriginationMustAttemptHangupWithoutRetry(t *testing.T)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "outcome unknown") {
if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "outcome unknown") || NativeCallPhase(err) != "originate" {
t.Fatalf("unknown originate outcome must never be reported as a completed call: %v", err)
}
if client.channels.issued != 1 || len(client.channels.hungup) != 1 || client.channels.hungup[0] != "exec-1" || client.closed != 1 {
@@ -186,8 +202,8 @@ func TestNativeCallUnknownBridgeOrMediaCreationCleansKnownIdentities(t *testing.
client := &testARIClient{channels: channels, bridges: bridges, bus: &testBus{sub: answerEvents{events: events}}}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil {
t.Fatal("unknown bridge/media outcome cannot be treated as a live audio session")
if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || NativeCallPhase(err) != map[string]string{"bridge": "bridge_create", "external-media": "external_media_create"}[step] {
t.Fatalf("unknown bridge/media outcome needs its exact failure phase: %v", err)
}
wantHungup := 1
if step == "external-media" {