diff --git a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs
index 00d1b1e82..f7d713525 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs
@@ -19,7 +19,9 @@ namespace Elsa.Workflows.Activities.StateMachine.Activities;
public class StateMachine : Activity
{
private const string PhaseEntering = "Entering";
+ private const string PhaseContinuing = "Continuing";
private const string CurrentStateProperty = "CurrentState";
+ private readonly StateMachineContinuation _automaticTransitionContinuation = new();
///
public StateMachine([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
@@ -201,9 +203,25 @@ public class StateMachine : Activity
private async ValueTask CompleteTransitionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
{
SetCurrentState(context, transition.To);
+
+ // Automatic transitions are normally evaluated inline after entering a state. If the
+ // target is part of a triggerless cycle, queue the next evaluation instead. This keeps
+ // the WF4 ordering (the transition has completed and the target state is current) while
+ // allowing the workflow scheduler to unwind the current call stack between iterations.
+ if (HasAutomaticCycle(GetCurrentState(context)))
+ {
+ await ScheduleAsync(context, _automaticTransitionContinuation, OnAutomaticTransitionContinuationCompletedAsync, PhaseContinuing, schedulingContext);
+ return;
+ }
+
await EnterStateAsync(context, schedulingContext);
}
+ private async ValueTask OnAutomaticTransitionContinuationCompletedAsync(ActivityCompletedContext context)
+ {
+ await EnterStateAsync(context.TargetContext, context.ChildContext);
+ }
+
private async ValueTask ScheduleTransitionActivityAsync(
ActivityExecutionContext context,
IActivity activity,
@@ -296,6 +314,8 @@ public class StateMachine : Activity
if (transition.Action != null)
yield return transition.Action;
}
+
+ yield return _automaticTransitionContinuation;
}
private void EnsureSupportedTriggerIdentities()
@@ -342,6 +362,29 @@ public class StateMachine : Activity
? []
: Transitions.Where(x => string.Equals(x.From, sourceState, StringComparison.Ordinal));
+ private bool HasAutomaticCycle(string? startingState)
+ {
+ if (string.IsNullOrWhiteSpace(startingState))
+ return false;
+
+ var visitedStates = new HashSet(StringComparer.Ordinal);
+ var statesToVisit = new Stack([startingState]);
+
+ while (statesToVisit.TryPop(out var state))
+ {
+ foreach (var transition in GetOutboundTransitions(state).Where(x => x.Trigger == null && FindState(x.To) != null))
+ {
+ if (string.Equals(transition.To, startingState, StringComparison.Ordinal))
+ return true;
+
+ if (visitedStates.Add(transition.To!))
+ statesToVisit.Push(transition.To!);
+ }
+ }
+
+ return false;
+ }
+
private Transition? FindTransitionByTrigger(ActivityExecutionContext context, IActivity trigger) =>
GetOutboundTransitions(GetCurrentState(context)).FirstOrDefault(x => ReferenceEquals(x.Trigger, trigger));
@@ -373,4 +416,8 @@ public class StateMachine : Activity
string.IsNullOrWhiteSpace(transition.Name)
? $"{transition.From}->{transition.To}"
: transition.Name;
+
+ private sealed class StateMachineContinuation : CodeActivity
+ {
+ }
}
diff --git a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs
index b504a48b8..a45a8e4c5 100644
--- a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs
+++ b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs
@@ -251,6 +251,63 @@ public class StateMachineTests
Assert.Equal(ActivityStatus.Completed, context.Status);
}
+ [Fact(DisplayName = "StateMachine yields an empty triggerless self-cycle to the workflow scheduler")]
+ public async Task EmptyTriggerlessSelfCycleYieldsToScheduler()
+ {
+ var stateMachine = new StateMachineActivity
+ {
+ InitialState = "A",
+ States = { new StateMachineState { Name = "A" } },
+ Transitions =
+ {
+ new Transition { From = "A", To = "A" }
+ }
+ };
+
+ var context = await ExecuteAsync(stateMachine);
+
+ Assert.Equal("A", context.GetProperty(CurrentStateProperty));
+ Assert.Equal(ActivityStatus.Running, context.Status);
+
+ var continuation = context.WorkflowExecutionContext.Scheduler.Take();
+ await CompleteScheduledActivityAsync(context, continuation.Activity);
+
+ Assert.Equal("A", context.GetProperty(CurrentStateProperty));
+ Assert.Equal(ActivityStatus.Running, context.Status);
+ Assert.Single(context.WorkflowExecutionContext.Scheduler.List());
+ }
+
+ [Fact(DisplayName = "StateMachine yields an empty triggerless two-state cycle to the workflow scheduler")]
+ public async Task EmptyTriggerlessTwoStateCycleYieldsToScheduler()
+ {
+ var stateMachine = new StateMachineActivity
+ {
+ InitialState = "A",
+ States =
+ {
+ new StateMachineState { Name = "A" },
+ new StateMachineState { Name = "B" }
+ },
+ Transitions =
+ {
+ new Transition { From = "A", To = "B" },
+ new Transition { From = "B", To = "A" }
+ }
+ };
+
+ var context = await ExecuteAsync(stateMachine);
+
+ Assert.Equal("B", context.GetProperty(CurrentStateProperty));
+ Assert.Equal(ActivityStatus.Running, context.Status);
+
+ var continuation = context.WorkflowExecutionContext.Scheduler.Take();
+ await CompleteScheduledActivityAsync(context, continuation.Activity);
+
+ Assert.Equal("A", context.GetProperty(CurrentStateProperty));
+ Assert.Equal(ActivityStatus.Running, context.Status);
+ Assert.Single(context.WorkflowExecutionContext.Scheduler.List());
+ }
+
[Fact(DisplayName = "StateMachine self-transition executes exit, action and entry in order")]
public async Task SelfTransitionExecutesExitActionAndEntryInOrder()
{