diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs
index 832407f024..b210d8b7e2 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs
@@ -356,23 +356,9 @@ internal class DurableWorkflowRunner
/// Runs the workflow execution loop using superstep-based processing.
///
///
- ///
- /// This method implements a Bulk Synchronous Parallel (BSP) style execution where each "superstep"
- /// processes all pending messages for all active executors. The workflow terminates when:
- ///
- ///
- /// - No more messages are pending (natural completion)
- /// - An executor calls RequestHaltAsync (explicit halt)
- /// - Maximum superstep limit is reached (safety limit)
- ///
- ///
- /// Fan-In Handling:
- ///
- ///
- /// For executors with multiple predecessors (Fan-In), the implementation waits for all predecessor
- /// results before invoking the executor. If the executor accepts an array type, all messages are
- /// aggregated into a JSON array.
- ///
+ /// Implements Bulk Synchronous Parallel (BSP) style execution where each superstep
+ /// processes all pending messages for active executors in parallel. Terminates when
+ /// no messages remain, halt is requested, or max supersteps reached.
///
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
@@ -385,152 +371,152 @@ internal class DurableWorkflowRunner
{
const int MaxSupersteps = 100;
- // Message queues per executor - stores (message, inputTypeName) tuples
- Dictionary> messageQueues = [];
+ SuperstepState state = new(workflow, plan);
+ EnqueueMessage(state.MessageQueues, plan.StartExecutorId, initialInput, typeof(string).FullName);
- // Last result from each executor (for edge condition evaluation and final result)
- Dictionary lastResults = [];
-
- // Track accumulated events and shared state
- DurableWorkflowCustomStatus customStatus = new();
- Dictionary sharedState = [];
-
- // Get executor bindings for creating WorkflowExecutorInfo
- Dictionary executorBindings = workflow.ReflectExecutors();
-
- // Initialize: queue input to start executor (initial input is a string)
- EnqueueMessage(messageQueues, plan.StartExecutorId, initialInput, typeof(string).FullName);
-
- int superstep = 0;
- bool haltRequested = false;
- string? finalOutput = null;
-
- while (superstep < MaxSupersteps && !haltRequested)
+ for (int superstep = 1; superstep <= MaxSupersteps; superstep++)
{
- superstep++;
-
- // Collect all executors with pending messages
- List activeExecutors = messageQueues
- .Where(kv => kv.Value.Count > 0)
- .Select(kv => kv.Key)
- .ToList();
-
- if (activeExecutors.Count == 0)
+ List inputs = PrepareExecutorInputs(state, logger);
+ if (inputs.Count == 0)
{
- break; // No more work
+ break;
}
- logger.LogDebug(
- "Superstep {Step}: {Count} active executor(s): {Executors}",
- superstep,
- activeExecutors.Count,
- string.Join(", ", activeExecutors));
+ logger.LogDebug("Superstep {Step}: {Count} active executor(s)", superstep, inputs.Count);
- // Prepare execution tasks for all active executors (for parallel dispatch)
- List<(string ExecutorId, string Input, string? InputTypeName, WorkflowExecutorInfo Info)> executorInputs = [];
+ // Dispatch all executors in parallel
+ Task[] tasks = inputs
+ .Select(e => this.DispatchExecutorAsync(context, e.Info, e.Input, e.InputTypeName, logger, state.CustomStatus, state.SharedState))
+ .ToArray();
- foreach (string executorId in activeExecutors)
+ string[] results = await Task.WhenAll(tasks).ConfigureAwait(true);
+
+ // Process results and route to successors
+ string? haltOutput = ProcessSuperstepResults(inputs, results, state, logger);
+ if (haltOutput is not null)
{
- Queue<(string Message, string? InputTypeName)> queue = messageQueues[executorId];
-
- // Check if this is a Fan-In executor that expects aggregated input
- bool isFanIn = plan.Predecessors.TryGetValue(executorId, out List? predecessors) && predecessors.Count > 1;
-
- string input;
- string? inputTypeName;
-
- if (isFanIn && queue.Count > 1)
- {
- // Fan-In: Aggregate all messages into a JSON array
- List messages = [];
- while (queue.Count > 0)
- {
- (string msg, _) = queue.Dequeue();
- messages.Add(msg);
- }
-
- input = AggregateMessagesToJsonArray(messages);
- inputTypeName = typeof(string[]).FullName;
-
- logger.LogDebug("Fan-In executor {ExecutorId}: aggregated {Count} messages", executorId, messages.Count);
- }
- else
- {
- // Normal case: process single message
- (input, inputTypeName) = queue.Dequeue();
- }
-
- WorkflowExecutorInfo executorInfo = CreateExecutorInfo(executorId, executorBindings);
- executorInputs.Add((executorId, input, inputTypeName, executorInfo));
+ return haltOutput;
}
- // Dispatch all executors in parallel by starting all tasks first, then awaiting together
- List> executorTasks = [];
- foreach ((string executorId, string input, string? inputTypeName, WorkflowExecutorInfo executorInfo) in executorInputs)
- {
- // Start the task without awaiting - this enables parallel dispatch
- Task task = this.DispatchExecutorAsync(
- context, executorInfo, input, inputTypeName, logger, customStatus, sharedState);
- executorTasks.Add(task);
- }
-
- // Wait for all executors to complete in parallel
- string[] rawResults = await Task.WhenAll(executorTasks).ConfigureAwait(true);
-
- // Process results and route messages
- for (int i = 0; i < executorInputs.Count; i++)
- {
- string executorId = executorInputs[i].ExecutorId;
- string rawResult = rawResults[i];
-
- (string result, List sentMessages) = UnwrapActivityResult(rawResult, customStatus, sharedState);
- lastResults[executorId] = result;
-
- // Check for explicit halt request (via RequestHaltAsync)
- if (HasHaltBeenRequested(customStatus, executorId))
- {
- haltRequested = true;
- finalOutput = result;
- logger.LogDebug("Halt requested by executor {ExecutorId}", executorId);
- break;
- }
-
- // Route messages sent via SendMessageAsync (takes priority for void-returning executors)
- if (sentMessages.Count > 0)
- {
- foreach (SentMessageInfo sentMessage in sentMessages)
- {
- if (!string.IsNullOrEmpty(sentMessage.Message))
- {
- // Route to successors with the sent message's type
- RouteMessageToSuccessors(
- executorId, sentMessage.Message, sentMessage.TypeName, plan, messageQueues, logger);
- }
- }
- }
- else if (!string.IsNullOrEmpty(result))
- {
- // Route executor's return value to successor executors via edges (for non-void executors)
- RouteMessageToSuccessors(
- executorId, result, plan, messageQueues, logger);
- }
-
- if (haltRequested)
- {
- break;
- }
- }
-
- UpdateCustomStatus(context, customStatus);
+ UpdateCustomStatus(context, state.CustomStatus);
}
- if (superstep >= MaxSupersteps)
+ return DetermineFinalResult(workflow, state.LastResults, state.CustomStatus);
+ }
+
+ ///
+ /// Holds mutable state for superstep-based workflow execution.
+ ///
+ private sealed class SuperstepState(Workflow workflow, WorkflowExecutionPlan plan)
+ {
+ public WorkflowExecutionPlan Plan { get; } = plan;
+ public Dictionary ExecutorBindings { get; } = workflow.ReflectExecutors();
+ public Dictionary> MessageQueues { get; } = [];
+ public Dictionary LastResults { get; } = [];
+ public Dictionary SharedState { get; } = [];
+ public DurableWorkflowCustomStatus CustomStatus { get; } = new();
+ }
+
+ ///
+ /// Represents prepared input for an executor ready for dispatch.
+ ///
+ private sealed record ExecutorInput(string ExecutorId, string Input, string? InputTypeName, WorkflowExecutorInfo Info);
+
+ ///
+ /// Prepares inputs for all active executors, handling Fan-In aggregation.
+ ///
+ private static List PrepareExecutorInputs(SuperstepState state, ILogger logger)
+ {
+ List inputs = [];
+
+ foreach ((string executorId, Queue<(string Message, string? InputTypeName)> queue) in state.MessageQueues)
{
- logger.LogWarning("Workflow reached maximum superstep limit ({MaxSteps})", MaxSupersteps);
+ if (queue.Count == 0)
+ {
+ continue;
+ }
+
+ bool isFanIn = state.Plan.Predecessors.TryGetValue(executorId, out List? predecessors) && predecessors.Count > 1;
+ (string input, string? inputTypeName) = isFanIn && queue.Count > 1
+ ? AggregateQueueMessages(queue, executorId, logger)
+ : queue.Dequeue();
+
+ inputs.Add(new ExecutorInput(executorId, input, inputTypeName, CreateExecutorInfo(executorId, state.ExecutorBindings)));
}
- // Return final output or last result from output executors
- return finalOutput ?? DetermineFinalResult(workflow, lastResults, customStatus);
+ return inputs;
+ }
+
+ ///
+ /// Aggregates all messages in a queue into a JSON array for Fan-In executors.
+ ///
+ private static (string Input, string? TypeName) AggregateQueueMessages(
+ Queue<(string Message, string? InputTypeName)> queue,
+ string executorId,
+ ILogger logger)
+ {
+ List messages = [];
+ while (queue.Count > 0)
+ {
+ messages.Add(queue.Dequeue().Message);
+ }
+
+ logger.LogDebug("Fan-In executor {ExecutorId}: aggregated {Count} messages", executorId, messages.Count);
+ return (AggregateMessagesToJsonArray(messages), typeof(string[]).FullName);
+ }
+
+ ///
+ /// Processes results from a superstep, updating state and routing messages.
+ ///
+ /// The halt output if halt was requested; otherwise null.
+ private static string? ProcessSuperstepResults(
+ List inputs,
+ string[] rawResults,
+ SuperstepState state,
+ ILogger logger)
+ {
+ for (int i = 0; i < inputs.Count; i++)
+ {
+ string executorId = inputs[i].ExecutorId;
+ (string result, List sentMessages) = UnwrapActivityResult(rawResults[i], state.CustomStatus, state.SharedState);
+ state.LastResults[executorId] = result;
+
+ if (HasHaltBeenRequested(state.CustomStatus, executorId))
+ {
+ logger.LogDebug("Halt requested by executor {ExecutorId}", executorId);
+ return result;
+ }
+
+ RouteExecutorOutput(executorId, result, sentMessages, state, logger);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Routes executor output (explicit messages or return value) to successors.
+ ///
+ private static void RouteExecutorOutput(
+ string executorId,
+ string result,
+ List sentMessages,
+ SuperstepState state,
+ ILogger logger)
+ {
+ if (sentMessages.Count > 0)
+ {
+ foreach (SentMessageInfo msg in sentMessages)
+ {
+ if (!string.IsNullOrEmpty(msg.Message))
+ {
+ RouteMessageToSuccessors(executorId, msg.Message, msg.TypeName, state.Plan, state.MessageQueues, logger);
+ }
+ }
+ }
+ else if (!string.IsNullOrEmpty(result))
+ {
+ RouteMessageToSuccessors(executorId, result, state.Plan, state.MessageQueues, logger);
+ }
}
///