Xander H
ServiceNow Employee
ServiceNow Employee

Introduction

Have you (or your customers) ever find the need to understand why dynamic scheduling behaved in a certain way? Why did it assign agent A to this work order task? Why did it not assign agent B? Or why does it refuse to assign ANY agent at all, what are my agents failing on? Have you built specific rules for scheduling, like matching skills, mandatory parts, rejected technicians or excluded technicians, that must be honored, even when doing manual scheduling? 

 

This article explains how we can leverage dynamic scheduling to implement these scheduling criteria, make sure they are satisfied for manual attempts too, and post all the relevant insights to work notes for full transparency.

 

Turning on the dynamic scheduling logs

First of all, we will need to ensure the dynamic scheduling logs are being written at all. Please navigate to Field Service > Dynamic Scheduling Administration > Properties and set "Show advanced agent recommendation logs to user" to true (com.snc.dynamic.scheduling.showlogs), then proceed with the following sections.

 

Extending the dynamic scheduling processor

In order to perform our logic, we need the output of the DynamicSchedulingProcessor's processing function. In order to achieve this, we will create a scripted extension point for the DynamicSchedulingProcessor that extends the `

runDynamicSchedulingProcessor` function, calls the OOTB function, captures its output, does "something" with it (i.e. exposes it in our desired way) and then returns it back to the main routine.
 
In order to accomplish this, we go to System Extension Points > Scripted Extension Points and look for global.DynamicSchedulingProcessorExtensionPoint.Using the related link Create Implementation we create a new implementation. I have given it the name DynamicSchedulingProcessorWithWorkNotes.
 
With this extension point now existing, we are interested in overriding `runDynamicSchedulingProcessor`, but we also need to make sure that the other functions still work. Therefore, we will also create a function `getExtensionPoint` that retrieves the next extension point. Without any other extension point existing, this will be the OOTB DynamicSchedulingProcessor.
 
I have implemented this function as follows:
	/* Retrieves the next extension point in line. */
	getExtensionPoint: function(record) {
        if (typeof GlideScriptedExtensionPoint == WMConfigurationConstants.UNDEFINED) {
            gs.error(gs.getMessage("DynamicSchedulingProcessorExtensionPoint: Scripted extension points are not enabled"));
            return null;
        }
        try {
            var ep = new GlideScriptedExtensionPoint().getExtensions(DynamicSchedulingConstants.DYNAMIC_SCHEDULING_PROCESSOR_EXTENSION_POINT);
            for (var e = 0; e < ep.length; e++) {
				// Skip this one.
				if(ep[e].type === this.type) {
					continue;
				}

                if (ep[e].handles == undefined || ep[e].handles(record)) {
                    ep[e].initialize();
                    return ep[e];
                }
            }
        } catch (ex) {
            gs.error(gs.getMessage('DynamicSchedulingProcessorExtensionPoint: Error fetching extension point ' + ex));
        }
        return null;
    },
This is just a copy of the standard DynamicSchedulingProcessorHelper's getExtensionPoint, but with a few lines added to skip this particular EP in its detection, so that it grabs the next one in line.
 
Now that we have a way to call the runDynamicSchedulingProcessor function from the OOTB implementation (or any other extension point that is configured), we can extend it and obtain the response before passing it back to whatever was calling the engine:
 
	runDynamicSchedulingProcessor: function(task_ids, task_table, unAssign, configObj, performAssignment, centralDispatch, appointmentBooking, appointmentRangeStart, appointmentRangeEnd, mustAssignedTasks, inputTaskGR, mode) {
		var response = this.getExtensionPoint(task_table).runDynamicSchedulingProcessor(task_ids, task_table, unAssign, configObj, performAssignment, centralDispatch, appointmentBooking, appointmentRangeStart, appointmentRangeEnd, mustAssignedTasks, inputTaskGR, mode);

		try {
			(new FSMUtils()).createDynamicSchedulingWorkNote(response, performAssignment);
		}
		catch(e) {
			gs.error("FSM exception in dynamic scheduling work note creation: " + e.getMessage());
		}

		return response;
	},
 
 The response object will now contain an object like such:
{
  "success": true,
  "data": {
    "recommendationResponseMap": {
      "ef1a8b34df113100dca6a5f59bf26327": {
        "success": false,
        "current": "WOT0008018",
        "message": "No resource is available at specified time(s)",
        "msgType": "error",
        "logs": [
          "Task : WOT0008018",
          "Assignment groups:[WM Agents]",
          "Groupusers: Chuck Farley,Colin Altonen,Cristina Sharper,WM Agent",
          "Matching rules: [Assignment : Assign Pending Dispatch Work Order Task]",
          "Resources match dimension \"Ignore Rejected Technician \":Chuck Farley,Colin Altonen,Cristina Sharper,WM Agent",
          "Resources match dimension \"Matching Skills For Dynamic Scheduling\":Chuck Farley",
          "Resources match dimension \"Current Distance From Task\":Chuck Farley",
          "Resources match dimension \"Consistent Assignment for SM tasks\":Chuck Farley",
          "Candidates from Matching Rule : Chuck Farley",
          "Window start: 2025-06-05 15:58:35: Window end:2025-06-19 15:58:35",
          "Window start after dependency check: 2025-06-05 15:58:35: Window end after dependency check:2025-06-19 15:58:35",
          "Qualifying agents: Chuck Farley",
          "Workblocks for Chuck Farley",
          "Sorted work blocks:",
          "Task : WOT0008018: Error: No resource is available at specified time(s)"
        ],
        "latLongInvalid": false,
        "foundQualifiedAgents": true,
        "calculatedData": {
          "candidatesFromMatchingRule": [
            "46c9e158a9fe198101d44d0d22cb640d"
          ]
        },
        "task_filter": "satisfied"
      }
    },
    "unassignedTaskMap": {},
    "taskInfoMap": {
      // ....
    },
    "isReverted": false,
    "latLongInvalid": false
  },
  "errorCode": "",
  "msgType": "success"
}

 

From this we can derive a number of useful pieces of info:

  • Was the attempt successful or was there some error? Keep in mind that "success" does not necessarily mean successfully scheduled, it just means the algorithm ran without any errors, whether it resulted in a schedule or not
  • For each of the tasks included (recommendation response map), what was the outcome?
    • Was it successful? In this case it does mean whether we were able to identify some resource
    • What was the human-readable message?
    • If we have the system property com.snc.dynamic.scheduling.showlogs enabled, we get a list of strings that make up the logs showing the output of the matching criteria, the window calculations, and the work blocks calculations. This is where we extract most of the useful information.
  •  Did we unassign anything to make place for this? (unassigned task map)

Based on this info, we can now create some logic that allows us to expose it, for instance as a work note. It is of course possible to do anything else with this such as storing it in a more structured way, but for the purpose of this article we will write to work notes.

 

Working with the output

In a new script include called FSMUtils I have built some logic that allows us to work with the response object as seen above. The example simply writes the execution log to work notes, which allows us exactly to see which agents sequentially "survive" the validation rules.

We can also see whether unassignment of a lower priority task has happened, and we can see in the `performAssignment` param whether assignment has actually been performed and completed (e.g. because this was a fully automated scheduling and the system told it to commit) or whether it was just a check.

 

This gives us the 3 functions below:

 

    createDynamicSchedulingWorkNote: function(schedulingOutputObj, performAssignment) {
        for (var wotId in schedulingOutputObj.data.recommendationResponseMap) {
            var result = schedulingOutputObj.data.recommendationResponseMap[wotId];

            var grTask = new GlideRecord('wm_task');
            grTask.get(wotId);

            grTask.work_notes = this.getDynamicSchedulingWorkNoteContents(result, performAssignment);

            grTask.update();

            // Create work note for unassigned tasks
            var unassignmentMap = schedulingOutputObj.data.unassignedTaskMap;
            if (Object.keys(unassignmentMap).length > 0) {
                this.createUnassignedTaskWorknote(unassignmentMap, grTask);
            }
        }
    },

    createUnassignedTaskWorknote: function(unassignmentMap, grAssignedTask) {
        Object.keys(unassignmentMap).forEach(function(wotId) {
            // Sometimes it considers itself for unassignment, in which case the message doesn't make sense.
            if (wotId === grAssignedTask.getValue('sys_id')) {
                return;
            }

            // Retrieve the unassigned task and add the note.
            var grWOT = new GlideRecord('wm_task');

            if (!grWOT.get(wotId)) {
                return;
            }

            grWOT.work_notes = gs.getMessage("Dynamic scheduling: this task was automatically unassigned in favour of higher priority task {0}.", [
                grAssignedTask.getValue('number')
            ]);
            grWOT.update();
        });
    },

    getDynamicSchedulingWorkNoteContents: function(result, performAssignment) {
        // Successful
        if (result.success) {
            var baseText = performAssignment ?
                "Scheduling assigning to {0} for start date {1}\n\n\n{2}" :
                "Scheduling proposed/allowed for {0} at start date {1}\n\n\n{2}";

            return gs.getMessage(baseText, [
                result.suggestedResources[0].agentName,
                result.suggestedResources[0].taskUpdates.expected_start,
                result.logs.join("\n")
            ]);
        }

        // Unsuccessful
        return gs.getMessage("Scheduling unsuccessful: {0} {1}\n\n\n{2}", [
            result.current,
            result.message,
            result.logs.join("\n")
        ]);
    },

 

Running dynamic scheduling on every assignment

At this point, our custom logic is called when dynamic scheduling is incurred. However, we may want to also run this logic for manual scheduling attempts. The use case is for us to validate that the assigned agent satisfies all dynamic scheduling criteria (e.g. has the required skills etc).

 

In order to run the validation on manual assignment attempts, I create a new business rule "Before assign, validate scheduling rules" that runs on the wm_task table, `before` Update, with a condition of:

 

[Assigned to] [Changes] AND 
[Assigned to] [is not empty] AND 
[Assigned to] [is not] [(blank)] AND 
[Window end] [at or after] [Current minute]

 

With the following script:

 

(function executeRule(current, previous /*null when async*/ ) {
    /* This is a workaround for the "Unassign task" right click refusing to work in some cases */
    if (!current.assigned_to.user_name) {
        return;
    }

    /* Check if we have validate_rules_before_assign property turned on and are even using dynamic scheduling. */
    var dispatchMethod = new sn_sm.SMConfiguration().getDispatchMethod(current);
    var enableRuleValidation = gs.getProperty('work.management.validate_rules_before_assign', 'true') === 'true';

    if (dispatchMethod != 'dynamic' || !enableRuleValidation) {
        return;
    }

    /* Check if agent is in the list of auto-assign rules allowed agents */
    var allowedAgents = (new sn_fsm_disp_wrkspc.DynamicSchedulingUtil()).getAllowedAgentIdsForTask(current.getValue('sys_id'));

    /* Agent validates criteria and is allowed for scheduling: happy path, proceed with scheduling. */
    if (allowedAgents.indexOf(current.getValue('assigned_to')) >= 0) {
        return;
    }

    /* Failure scenario: post dynamic scheduling logs as a work note, add warning message and post to logs. */
    postLogsAsWorknote();

    current.setAbortAction(true); 

    gs.addErrorMessage(gs.getMessage("Agent {0} does not satisfy the scheduling criteria. Please see work notes for more detailed information.", current.getDisplayValue('assigned_to')));

    function postLogsAsWorknote() {
        var noShowObj = {
            "alwaysShow": false,
            "showField": false,
        };

        var configObj = {
            "Location": noShowObj,
            "Schedule Status": noShowObj,
            "Agent Status": noShowObj,
            "Available Parts": noShowObj,
            "Matching Skills": noShowObj,
            "Distance to Task": noShowObj,
            "Groups": noShowObj,
        };

        (new global.DynamicSchedulingProcessor()).process([current.sys_id], 'wm_task', false, configObj, false);
    }
})(current, previous);

 This script also implements a system property allowing us to control the behaviour: work.management.validate_rules_before_assign that should be set to true (in case of absence, defaults to true).

 

We can see that calling the dynamic scheduling engine for posting the work notes is actually NOT the part that checks whether the agent satisfies. For that, we need one more script in the scope FSM Configurable Dispatcher Workspace.

 

The full reason is that we want to call the OOTB function 

sn_fsm_disp_wrkspc.DispatcherWorkspaceAgentRecommendationUtil.getRecommendedAgentListData(...) and let the system set all the right parameters, but we can only do it from the scope FSM Configurable Dispatcher Workspace. Therefore, we create our own script include in this scope, calling it `DynamicSchedulingUtil` and we set it to be accessible from all scopes. We can now build our accessible wrapper around the OOTB call:
 
var DynamicSchedulingUtil = Class.create();
DynamicSchedulingUtil.prototype = {
    initialize: function() {},

    getAllowedAgentIdsForTask: function(wotId) {
        var noShowObj = {
            "alwaysShow": false,
            "showField": false,
        };
        
		var result = (new sn_fsm_disp_wrkspc.DispatcherWorkspaceAgentRecommendationUtil()).getRecommendedAgentListData(wotId, {
            "Location": noShowObj,
			"Schedule Status": noShowObj,
            "Agent Status": noShowObj,
			"Available Parts": noShowObj,
			"Matching Skills": noShowObj,
			"Distance to Task": noShowObj,
			"Groups": noShowObj,
        });

		if(!result || result.type !== "success") {
			return [];
		}

		return Object.keys(result.recommendedAgents);
    },

    type: 'DynamicSchedulingUtil'
};

 

We can now simply pass in a WOT ID and have the system identify the allowed agents using all the correct parameters. Unfortunately we can not use our earlier response object: it returns whether our agent satisfies all matching rules, but not whether they are also available from a working hours perspective. This call, however, does actually return that. If you only want to check whether all dynamic scheduling matching rules validate and do not want to check for work blocks, you can also skip this step and use the response object's information directly.

 

Putting it all together

In summary, we now have a number of components working together to provide insights in scheduling attempts, both manual and automatic. We have done this without modifying a single OOTB artifact, which means our tech debt is minimal, aside from having a "high code" solution for this.

 

  • An extension of the OOTB DynamicSchedulingProcessor that allows us to intercept the dynamic scheduling response object and do something with it
  • A script include FSMUtils that has all the logic for understanding the response object and posting the work notes
  • A script include DynamicSchedulingUtil that wraps around the OOTB 

    DispatcherWorkspaceAgentRecommendationUtil to identify which agents satisfy all dynamic scheduling criteria and assignment logic
  • A business rule "Before assign, validate scheduling rules" that is triggered on every scheduling attempt (manual or automatic) and validates the dynamic scheduling criteria before allowing scheduling
 And now, whether we use fully automated assignment, the manually clicked Auto assign button, or just a manual scheduling attempt by changing the Assigned to field - our validation rules are run, assignment cancelled if not satisfied, and the full output posted to work notes!
 
The Dispatcher workspace throwing an error message upon failing validation of our (dynamic) scheduling criteria, despite being a manual scheduling effortThe Dispatcher workspace throwing an error message upon failing validation of our (dynamic) scheduling criteria, despite being a manual scheduling effortScheduling criteria logs posted to work notes, giving insights into the scheduling logic and where each agent failed to satisfy the criteriaScheduling criteria logs posted to work notes, giving insights into the scheduling logic and where each agent failed to satisfy the criteria

 

 

Comments
Joshua Chen FX
Mega Sage

this is great @Xander H , will this be part of a future product release, or this is the custom solution for now?

do you have something a solution to expose the schedule optimization logs into work notes too?

Xander H
ServiceNow Employee
ServiceNow Employee

Hi @Joshua Chen FX , thanks for your response. We are investigating whether this is something that can be incorporated into the product but for now is custom. If this ends up being roadmapped at some point, I will update this article.

 

For now I do not have a similar solution for SO, but it is on our list to investigate. 

williamkos
Tera Contributor

Hi @Xander H,

 

I've tried above setup and am running into issues regarding the extension point. When performing an auto assignment I get the feedback: "Invalid Dynamic Scheduling Configuration". While pinpointing the issue, I see that the SI DynamicSchedulingProcessorHelper calls the EP DynamicSchedulingProcessorWithWorkNotes for functions which have not been defined, therefore resulting in a failed configuration. My EP has both the 

getExtensionPoint and runDynamicSchedulingProcessor function and I've not touched the helper SI at all.
 
Am I doing something wrong regarding the order of the EPs? The OOTB one has 10000 and the newly created one has been granted an order of 100, which makes it to always be chosen by the SI DynamicSchedulingProcessorHelper. Turning it around, however, makes the whole extension point idea non-functioning.
 
Another direction I was thinking is that I should maybe update the getExtensionPoint function in the DynamicSchedulingProcessorHelper SI? Your solution, however, will not work in this case, as the types will not match.
 
There is probably something I'm missing or doing wrong, but I can't find what. Do you have any idea?

 

 

Xander H
ServiceNow Employee
ServiceNow Employee

Hi @williamkos, as you have found, the EP needs to be able to expose all other functions of the initial implementation. Therefore you can add all other functions with a call to the next-in-line implementation, something like:

var DynamicSchedulingProcessorWithWorkNotes = Class.create();
DynamicSchedulingProcessorWithWorkNotes.prototype = {
	initialize: function() {
    },

	handles: function(record) {
		return true;
	},
	
	runDynamicSchedulingProcessor: function(task_ids, task_table, unAssign, configObj, performAssignment, centralDispatch, appointmentBooking, appointmentRangeStart, appointmentRangeEnd, mustAssignedTasks, inputTaskGR, mode) {
		var response = (new global.DynamicSchedulingProcessor()).runDynamicSchedulingProcessor(task_ids, task_table, unAssign, configObj, performAssignment, centralDispatch, appointmentBooking, appointmentRangeStart, appointmentRangeEnd, mustAssignedTasks, inputTaskGR, mode);

		try {
			(new FSMUtils()).createDynamicSchedulingWorkNote(response, performAssignment);
		}
		catch(e) {
			gs.error("FSM exception in dynamic scheduling work note creation: " + e.getMessage());
		}

		return response;
	},

	getDynamicSchedulingConfig: function(task, territory){
		var extensionPoint = this.getExtensionPoint(task);
        return extensionPoint.getDynamicSchedulingConfig(task, territory);
	},

	getAutoAssignTaskFilters: function(task) {
		var extensionPoint = this.getExtensionPoint(task);
        return extensionPoint.getAutoAssignTaskFilters(task);
	},

	getTaskIDsMatchingTaskFilter: function(taskFilterGR, configGR) {
		var extensionPoint = this.getExtensionPoint(taskFilterGR.getValue(DynamicSchedulingConstants.TABLE));
		return extensionPoint.getTaskIDsMatchingTaskFilter(taskFilterGR, configGR);
	},

	getSMConfigByDynamicSchedulingConfigRecord: function (tableName, dynamicSchedulingConfig) {
		var extensionPoint = this.getExtensionPoint(tableName);
		return extensionPoint.getSMConfigByDynamicSchedulingConfigRecord(tableName, dynamicSchedulingConfig);
	},

	getTaskTable: function(tableName, getBaseTable) {
		var extensionPoint = this.getExtensionPoint(tableName);
		return extensionPoint.getTaskTable(tableName, getBaseTable);
	},

	getTaskRecordFromSysClassName: function(taskGR) {
		var extensionPoint = this.getExtensionPoint(taskGR);
		return extensionPoint.getTaskRecordFromSysClassName(taskGR);
	},

	getDynamicSchedulingConfigIDForUnassignment: function(taskGR, configID) {
		var extensionPoint = this.getExtensionPoint(taskGR);
		return extensionPoint.getDynamicSchedulingConfigIDForUnassignment(taskGR, configID);
	},

	allowOptimizationObjectInitialization: function(task_table) {
		var extensionPoint = this.getExtensionPoint(task_table);
		return extensionPoint.allowOptimizationObjectInitialization(task_table);
	},

	/* Retrieves the next extension point in line. */
	getExtensionPoint: function(record) {
        if (typeof GlideScriptedExtensionPoint == WMConfigurationConstants.UNDEFINED) {
            gs.error(gs.getMessage("DynamicSchedulingProcessorExtensionPoint: Scripted extension points are not enabled"));
            return null;
        }
        try {
            var ep = new GlideScriptedExtensionPoint().getExtensions(DynamicSchedulingConstants.DYNAMIC_SCHEDULING_PROCESSOR_EXTENSION_POINT);
            for (var e = 0; e < ep.length; e++) {
				// Skip this one.
				if(ep[e].type === this.type) {
					continue;
				}

                if (ep[e].handles == undefined || ep[e].handles(record)) {
                    ep[e].initialize();
                    return ep[e];
                }
            }
        } catch (ex) {
            gs.error(gs.getMessage('DynamicSchedulingProcessorExtensionPoint: Error fetching extension point ', ex));
        }
        return null;
    },

    type: 'DynamicSchedulingProcessorWithWorkNotes'
};
williamkos
Tera Contributor

Hi @Xander H,

 

Thanks so much! I was already wondering if I had to overwrite every single function. I've had this issue before regarding the CSM/ITSM integration, where the extension point got overwritten for just a single function and on go-live date everything broke down (yes poor testing, but still). This neat trick with the getExtensionPoint function will help me at that customer as well, since for a quick and dirty hotfix I just copied every OOTB function, but now I can resolve it without risking upgrade issues. 

 

The handles function in here is kinda funny, since it always results in true. I guess the addition of the record to the getExtensionPoint function adds little value here, but still good to just stay close to OOTB.

 

Again, thanks for your help, it works like a charm!

Version history
Last update:
‎06-19-2025 12:16 AM
Updated by:
Contributors