feat(agent): select isolated real adapter without mock fallback

This commit is contained in:
2026-10-04 14:36:43 +08:00
parent fe3aac19c2
commit 5f052c1db0
3 changed files with 47 additions and 7 deletions
+13 -5
View File
@@ -72,7 +72,7 @@ func (s *Server) GetLoadedSIP(ctx context.Context, req *agentpb.GetLoadedSIPRequ
// ExecuteApproved is mock-only until real Agent/Asterisk loading and supplier
// contracts have been separately verified. It persists a one-shot unknown
// execution BEFORE calling the mock adapter. Ambiguous attempts never redial.
// execution BEFORE calling the selected adapter. Ambiguous attempts never redial.
func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprovedRequest) (*agentpb.ExecuteApprovedResponse, error) {
if req == nil || req.Meta == nil {
return nil, status.Error(codes.InvalidArgument, "approved execution metadata is required")
@@ -87,8 +87,16 @@ func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprov
if req.DispatcherId != dispatcherID {
return nil, status.Error(codes.PermissionDenied, "Dispatcher does not own the active Agent session")
}
if s.mode != "mock" || s.mockApprovedOriginate == nil {
return nil, status.Error(codes.FailedPrecondition, "approved origination requires the isolated mock adapter")
originate := s.mockApprovedOriginate
switch s.mode {
case "mock":
case "nonprod-real":
originate = s.nonprodRealOriginate
default:
return nil, status.Error(codes.FailedPrecondition, "approved origination mode is disabled")
}
if originate == nil {
return nil, status.Error(codes.FailedPrecondition, "approved origination adapter is unavailable")
}
const maxTimeoutMS = int64(math.MaxInt64 / int64(time.Millisecond))
if req.TenantId <= 0 || req.TaskId == "" || req.SourceEventId == "" || req.CallId == "" || req.SelectedTrunkId == "" || req.CallerId == "" || req.Callee == "" || req.DialedCallee == "" || req.RingTimeoutMs <= 0 || req.RingTimeoutMs > maxTimeoutMS || req.MaxCallDurationMs <= 0 || req.MaxCallDurationMs > maxTimeoutMS || req.SipRevision <= 0 || req.Meta.IdempotencyKey != req.SourceEventId {
@@ -139,7 +147,7 @@ func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprov
MaxCallDuration: time.Duration(req.MaxCallDurationMs) * time.Millisecond,
DialBefore: time.UnixMilli(req.DialBeforeUnixMs), AI: bound,
}
if err := s.mockApprovedOriginate(ctx, approved); err != nil {
if err := originate(ctx, approved); err != nil {
switch {
case errors.Is(err, ErrApprovedDialExpired):
log.Printf("Agent approved execution refused before dial: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
@@ -149,7 +157,7 @@ func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprov
return nil, status.Error(codes.FailedPrecondition, "approved call was not ready before dial")
default:
log.Printf("Agent approved execution outcome unknown: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
return nil, status.Error(codes.Unavailable, "approved mock execution outcome unknown")
return nil, status.Error(codes.Unavailable, "approved execution outcome unknown")
}
}
return &agentpb.ExecuteApprovedResponse{CallId: req.CallId, Accepted: true}, nil
+28
View File
@@ -77,6 +77,34 @@ func activatedApprovedServer(t *testing.T, now time.Time, statePath string, disp
return s
}
func TestExecuteApprovedRealModeNeverUsesMockAdapter(t *testing.T) {
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
req := approvedTestRequest(t, now)
s := activatedApprovedServer(t, now, filepath.Join(t.TempDir(), "agent-session.json"), req.DispatcherId, 1, func(context.Context, ApprovedExecution) error {
t.Fatal("real mode used mock adapter")
return nil
})
s.mode = "nonprod-real"
if _, err := s.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("real mode without its own adapter must refuse before journaling: %v", err)
}
attempts := 0
s.nonprodRealOriginate = func(_ context.Context, approved ApprovedExecution) error {
attempts++
if approved.DialedCallee != req.DialedCallee || approved.CallerID != req.CallerId || approved.SelectedTrunkID != req.SelectedTrunkId {
t.Fatal("signed real dial identity was altered")
}
return nil
}
response, err := s.ExecuteApproved(context.Background(), req)
if err != nil || response == nil || !response.Accepted || attempts != 1 {
t.Fatalf("real mode should dispatch exactly once through its adapter: response=%v err=%v attempts=%d", response, err, attempts)
}
if _, err := s.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
t.Fatalf("real mode must not redial an accepted identity: err=%v attempts=%d", err, attempts)
}
}
func TestExecuteApprovedBindsConfigAndPreventsRedialAcrossRestart(t *testing.T) {
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
req := approvedTestRequest(t, now)
+6 -2
View File
@@ -21,8 +21,8 @@ import (
"google.golang.org/protobuf/proto"
)
// ServerOptions contains deployment-bound identity and mock policy inputs.
// Production credentials are supplied to grpc.Server through TLS credentials.
// ServerOptions contains deployment-bound identity and call policy inputs.
// Agent credentials are supplied to grpc.Server through TLS credentials.
type ServerOptions struct {
Mode string
Status *agentpb.AgentStatus
@@ -38,6 +38,8 @@ type ServerOptions struct {
ApplySIP func(context.Context, []byte) (map[string]int64, error)
// The mock may have issued a call even if its outcome is unknown.
MockApprovedOriginate func(context.Context, ApprovedExecution) error
// NonprodRealOriginate is never selected by the isolated Mock mode.
NonprodRealOriginate func(context.Context, ApprovedExecution) error
// ApprovedTaskCalls is shared with the approved call runner; nil rejects task controls.
ApprovedTaskCalls *agent.TaskCalls
}
@@ -58,6 +60,7 @@ type Server struct {
applySIP func(context.Context, []byte) (map[string]int64, error)
sipMu sync.Mutex
mockApprovedOriginate func(context.Context, ApprovedExecution) error
nonprodRealOriginate func(context.Context, ApprovedExecution) error
approvedTaskCalls *agent.TaskCalls
sessions *SessionRegistry
@@ -93,6 +96,7 @@ func NewServer(options ServerOptions) *Server {
loadedSIP: options.LoadedSIP,
applySIP: options.ApplySIP,
mockApprovedOriginate: options.MockApprovedOriginate,
nonprodRealOriginate: options.NonprodRealOriginate,
approvedTaskCalls: options.ApprovedTaskCalls,
sessions: NewSessionRegistry(options.StatePath),
}