From 051e12f864e54dd355395a7f8e0f2ebd230c0316 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 31 Aug 2026 02:47:29 +0200 Subject: [PATCH 1/5] Fix StateMachine transition lifecycle ordering --- .../StateMachine/Activities/StateMachine.cs | 54 +++++++++--- .../StateMachine/StateMachineTests.cs | 86 +++++++++++++++++-- 2 files changed, 121 insertions(+), 19 deletions(-) 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 0a3c7895c..cfa0045ae 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs @@ -98,7 +98,7 @@ public class StateMachine : Activity private async ValueTask ScheduleOutboundTriggersAsync(ActivityExecutionContext context, ActivityExecutionContext? schedulingContext = null) { - var outboundTransitions = GetOutboundTransitions(GetCurrentState(context)).Where(x => x.Trigger != null && FindState(x.To) != null).ToList(); + var outboundTransitions = GetOutboundTransitions(GetCurrentState(context)).Where(x => FindState(x.To) != null).ToList(); if (!outboundTransitions.Any()) { @@ -106,7 +106,18 @@ public class StateMachine : Activity return; } - foreach (var transition in outboundTransitions) + foreach (var transition in outboundTransitions.Where(x => x.Trigger == null)) + { + if (await TryTakeTransitionAsync(context, transition, schedulingContext)) + return; + } + + var triggeredTransitions = outboundTransitions.Where(x => x.Trigger != null).ToList(); + + if (!triggeredTransitions.Any()) + return; + + foreach (var transition in triggeredTransitions) await ScheduleAsync(context, transition.Trigger!, OnTriggerCompletedAsync, GetTransitionKey(transition), schedulingContext); } @@ -118,25 +129,40 @@ public class StateMachine : Activity if (transition == null || !IsCurrentSource(targetContext, transition) || FindState(transition.To) == null) return; - var canTransition = transition.Condition == null || await EvaluateConditionAsync(targetContext, transition.Condition); - - if (!canTransition) + if (!await TryTakeTransitionAsync(targetContext, transition, context.ChildContext)) { if (transition.Trigger != null) await ScheduleAsync(targetContext, transition.Trigger, OnTriggerCompletedAsync, GetTransitionKey(transition), context.ChildContext); - - return; } + } - await CancelCompetingTriggersAsync(targetContext, transition, context.ChildContext); + private async ValueTask TryTakeTransitionAsync( + ActivityExecutionContext context, + Transition transition, + ActivityExecutionContext? schedulingContext) + { + var canTransition = transition.Condition == null || await EvaluateConditionAsync(context, transition.Condition); + + if (!canTransition) + return false; + + await CancelCompetingTriggersAsync(context, transition, schedulingContext); + await ExitStateAsync(context, transition, schedulingContext); + return true; + } + + private async ValueTask RunTransitionActionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null) + { + if (!IsCurrentSource(context, transition)) + return; if (transition.Action != null) { - await ScheduleTransitionActivityAsync(targetContext, transition.Action, transition, OnTransitionActionCompletedAsync, context.ChildContext); + await ScheduleTransitionActivityAsync(context, transition.Action, transition, OnTransitionActionCompletedAsync, schedulingContext); return; } - await ExitStateAsync(targetContext, transition, context.ChildContext); + await CompleteTransitionAsync(context, transition, schedulingContext); } private async ValueTask OnTransitionActionCompletedAsync(ActivityCompletedContext context) @@ -147,7 +173,7 @@ public class StateMachine : Activity if (transition == null || !IsCurrentSource(targetContext, transition)) return; - await ExitStateAsync(targetContext, transition, context.ChildContext); + await CompleteTransitionAsync(targetContext, transition, context.ChildContext); } private async ValueTask ExitStateAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null) @@ -160,7 +186,7 @@ public class StateMachine : Activity return; } - await CompleteTransitionAsync(context, transition, schedulingContext); + await RunTransitionActionAsync(context, transition, schedulingContext); } private async ValueTask OnStateExitCompletedAsync(ActivityCompletedContext context) @@ -171,7 +197,7 @@ public class StateMachine : Activity if (transition == null || !IsCurrentSource(targetContext, transition)) return; - await CompleteTransitionAsync(targetContext, transition, context.ChildContext); + await RunTransitionActionAsync(targetContext, transition, context.ChildContext); } private async ValueTask CompleteTransitionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null) @@ -207,7 +233,7 @@ public class StateMachine : Activity await context.ScheduleActivityAsync(activity, options); } - private async Task CancelCompetingTriggersAsync(ActivityExecutionContext context, Transition winningTransition, ActivityExecutionContext winningTriggerContext) + private async Task CancelCompetingTriggersAsync(ActivityExecutionContext context, Transition winningTransition, ActivityExecutionContext? winningTriggerContext) { var competingTriggerIds = GetOutboundTransitions(winningTransition.From) .Where(x => !ReferenceEquals(x, winningTransition)) diff --git a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs index 9fd647e9e..612bab136 100644 --- a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs @@ -83,18 +83,19 @@ public class StateMachineTests Assert.Equal(ActivityStatus.Running, context.Status); } - [Fact(DisplayName = "StateMachine executes accepted transition action, source exit, target entry and target triggers")] + [Fact(DisplayName = "StateMachine executes source exit, accepted transition action, target entry and target triggers")] public async Task ExecutesAcceptedTransitionPath() { var context = await ExecuteAndEnterNewStateAsync(); await CompleteScheduledActivityAsync(context, _payTrigger); + Assert.True(context.HasScheduledActivity(_newExit)); + Assert.False(context.HasScheduledActivity(_payAction)); + + await CompleteScheduledActivityAsync(context, _newExit); Assert.True(context.HasScheduledActivity(_payAction)); await CompleteScheduledActivityAsync(context, _payAction); - Assert.True(context.HasScheduledActivity(_newExit)); - - await CompleteScheduledActivityAsync(context, _newExit); Assert.Equal("Paid", _stateMachine.CurrentState); Assert.Equal("Paid", context.GetProperty(CurrentStateProperty)); Assert.True(context.HasScheduledActivity(_paidEntry)); @@ -120,6 +121,80 @@ public class StateMachineTests Assert.True(context.HasScheduledActivity(_paidTrigger)); } + [Fact(DisplayName = "StateMachine evaluates a triggerless transition immediately after state entry")] + public async Task TriggerlessTransitionIsEvaluatedImmediately() + { + var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); + payTransition.Trigger = null; + payTransition.Condition = null; + var context = await ExecuteAsync(); + + await CompleteScheduledActivityAsync(context, _newEntry); + + Assert.True(context.HasScheduledActivity(_newExit)); + Assert.False(context.HasScheduledActivity(_cancelTrigger)); + + await CompleteScheduledActivityAsync(context, _newExit); + await CompleteScheduledActivityAsync(context, _payAction); + + Assert.Equal("Paid", context.GetProperty(CurrentStateProperty)); + Assert.True(context.HasScheduledActivity(_paidEntry)); + } + + [Fact(DisplayName = "StateMachine schedules event triggers when triggerless transition conditions are false")] + public async Task FalseTriggerlessConditionAllowsTriggeredTransitions() + { + var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); + payTransition.Trigger = null; + payTransition.Condition = new(false); + var context = await ExecuteAsync(); + + await CompleteScheduledActivityAsync(context, _newEntry); + + Assert.Equal("New", _stateMachine.CurrentState); + Assert.False(context.HasScheduledActivity(_newExit)); + Assert.True(context.HasScheduledActivity(_cancelTrigger)); + Assert.Equal(ActivityStatus.Running, context.Status); + } + + [Fact(DisplayName = "StateMachine leaves an all-false triggerless state active without rescheduling")] + public async Task AllFalseTriggerlessTransitionsDoNotCompleteOrSpin() + { + _stateMachine.Transitions.Remove(_stateMachine.Transitions.Single(x => x.Name == "Cancel")); + var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); + payTransition.Trigger = null; + payTransition.Condition = new(false); + var context = await ExecuteAsync(); + + await CompleteScheduledActivityAsync(context, _newEntry); + + Assert.Equal("New", _stateMachine.CurrentState); + Assert.Equal(ActivityStatus.Running, context.Status); + Assert.False(context.HasScheduledActivity(_newExit)); + Assert.False(context.HasScheduledActivity(_payAction)); + Assert.False(context.HasScheduledActivity(_paidEntry)); + Assert.DoesNotContain(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context); + } + + [Fact(DisplayName = "StateMachine self-transition executes exit, action and entry in order")] + public async Task SelfTransitionExecutesExitActionAndEntryInOrder() + { + var transition = _stateMachine.Transitions.Single(x => x.Name == "Pay"); + transition.To = "New"; + var context = await ExecuteAndEnterNewStateAsync(); + + await CompleteScheduledActivityAsync(context, _payTrigger); + Assert.True(context.HasScheduledActivity(_newExit)); + Assert.False(context.HasScheduledActivity(_payAction)); + + await CompleteScheduledActivityAsync(context, _newExit); + Assert.True(context.HasScheduledActivity(_payAction)); + + await CompleteScheduledActivityAsync(context, _payAction); + Assert.Equal("New", _stateMachine.CurrentState); + Assert.True(context.HasScheduledActivity(_newEntry)); + } + [Fact(DisplayName = "StateMachine false transition condition leaves competing triggers active")] public async Task FalseConditionLeavesCompetingTriggersActive() { @@ -242,10 +317,11 @@ public class StateMachineTests var context = await ExecuteAndEnterNewStateAsync(); await CompleteScheduledActivityAsync(context, _payTrigger); + await CompleteScheduledActivityAsync(context, _newExit); context.SetProperty(CurrentStateProperty, "Paid"); await CompleteScheduledActivityAsync(context, _payAction); - Assert.False(context.HasScheduledActivity(_newExit)); + Assert.False(context.HasScheduledActivity(_paidEntry)); } private async Task ExecuteAndEnterNewStateAsync() From 6a2e530d77a947fa9abca822f41b4cbb903d69e6 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 31 Aug 2026 02:54:01 +0200 Subject: [PATCH 2/5] Enforce StateMachine trigger identity boundaries --- .../StateMachine/Activities/StateMachine.cs | 43 ++++++++++++++++--- .../StateMachine/StateMachineTests.cs | 24 +++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) 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 cfa0045ae..00d1b1e82 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs @@ -51,15 +51,13 @@ public class StateMachine : Activity /// [JsonIgnore] [Browsable(false)] - public IEnumerable Activities => - States.SelectMany(x => new[] { x.Entry, x.Exit }) - .Concat(Transitions.SelectMany(x => new[] { x.Trigger, x.Action })) - .Where(x => x != null) - .Cast(); + public IEnumerable Activities => GetActivities(); /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { + EnsureSupportedTriggerIdentities(); + var currentState = GetCurrentState(context); SetCurrentState(context, string.IsNullOrWhiteSpace(currentState) ? InitialState : currentState); @@ -281,6 +279,41 @@ public class StateMachine : Activity private string? GetCurrentState(ActivityExecutionContext context) => context.GetProperty(CurrentStateProperty) ?? CurrentState; + private IEnumerable GetActivities() + { + foreach (var stateActivity in States.SelectMany(x => new[] { x.Entry, x.Exit }).Where(x => x != null)) + yield return stateActivity!; + + var seenTriggerInstances = new HashSet(ReferenceEqualityComparer.Instance); + var seenTriggerIds = new HashSet(StringComparer.Ordinal); + + foreach (var transition in Transitions) + { + if (transition.Trigger != null && seenTriggerInstances.Add(transition.Trigger) && + (string.IsNullOrWhiteSpace(transition.Trigger.Id) || seenTriggerIds.Add(transition.Trigger.Id))) + yield return transition.Trigger; + + if (transition.Action != null) + yield return transition.Action; + } + } + + private void EnsureSupportedTriggerIdentities() + { + var triggers = Transitions.Where(x => x.Trigger != null).Select(x => x.Trigger!).ToList(); + var seenInstances = new HashSet(ReferenceEqualityComparer.Instance); + var seenIds = new HashSet(StringComparer.Ordinal); + + foreach (var trigger in triggers) + { + var sharesInstance = !seenInstances.Add(trigger); + var duplicatesId = !string.IsNullOrWhiteSpace(trigger.Id) && !seenIds.Add(trigger.Id); + + if (sharesInstance || duplicatesId) + throw new InvalidOperationException("StateMachine transitions cannot share a Trigger activity in Elsa 3.8. Give each transition its own trigger activity with a unique ID."); + } + } + private void SetCurrentState(ActivityExecutionContext context, string? state) { CurrentState = state; diff --git a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs index 612bab136..640a329c1 100644 --- a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs @@ -201,6 +201,7 @@ public class StateMachineTests var context = await ExecuteAndEnterNewStateAsync(); _stateMachine.Transitions.Single(x => x.Name == "Pay").Condition = new(false); var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger); + var cancelBookmark = cancelTriggerContext.CreateBookmark("cancel"); var scheduledPayTriggerCount = CountScheduledActivities(context, _payTrigger); await CompleteScheduledActivityAsync(context, _payTrigger); @@ -209,6 +210,7 @@ public class StateMachineTests Assert.False(context.HasScheduledActivity(_payAction)); Assert.Equal(scheduledPayTriggerCount + 1, CountScheduledActivities(context, _payTrigger)); Assert.NotEqual(ActivityStatus.Canceled, cancelTriggerContext.Status); + Assert.Contains(cancelBookmark, context.WorkflowExecutionContext.Bookmarks); } [Fact(DisplayName = "StateMachine cancels competing outbound triggers when a transition wins")] @@ -216,10 +218,32 @@ public class StateMachineTests { var context = await ExecuteAndEnterNewStateAsync(); var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger); + var cancelBookmark = cancelTriggerContext.CreateBookmark("cancel"); await CompleteScheduledActivityAsync(context, _payTrigger); Assert.Equal(ActivityStatus.Canceled, cancelTriggerContext.Status); + Assert.DoesNotContain(cancelBookmark, context.WorkflowExecutionContext.Bookmarks); + } + + [Fact(DisplayName = "StateMachine rejects transitions that share a trigger instance")] + public async Task SharedTriggerInstanceIsRejected() + { + _stateMachine.Transitions.Single(x => x.Name == "Cancel").Trigger = _payTrigger; + + var exception = await Assert.ThrowsAsync(() => ExecuteAsync()); + + Assert.Contains("cannot share a Trigger activity", exception.Message); + } + + [Fact(DisplayName = "StateMachine rejects transition triggers with duplicate activity IDs")] + public async Task DuplicateTriggerIdIsRejected() + { + _cancelTrigger.Id = _payTrigger.Id; + + var exception = await Assert.ThrowsAsync(() => ExecuteAsync()); + + Assert.Contains("unique ID", exception.Message); } [Fact(DisplayName = "StateMachine removes re-armed competing triggers when a different transition wins")] From 5a820ce33eb5dc23ca124dbd9a6cf7b96a5ecbfe Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 31 Aug 2026 03:11:19 +0200 Subject: [PATCH 3/5] Expand StateMachine conformance coverage --- .../StateMachine/StateMachineTests.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs index 640a329c1..b504a48b8 100644 --- a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs @@ -105,6 +105,40 @@ public class StateMachineTests Assert.Equal(ActivityStatus.Running, context.Status); } + [Fact(DisplayName = "StateMachine exposes source entry, trigger, exit, action and target entry order")] + public async Task AcceptedTransitionExecutesObservableLifecycleOrder() + { + var lifecycle = new List(); + var context = await ExecuteAsync(); + + Assert.True(context.HasScheduledActivity(_newEntry)); + Assert.False(context.HasScheduledActivity(_payTrigger)); + + await CompleteScheduledActivityAsync(context, _newEntry); + lifecycle.Add("source entry"); + Assert.True(context.HasScheduledActivity(_payTrigger)); + + await CompleteScheduledActivityAsync(context, _payTrigger); + lifecycle.Add("trigger"); + Assert.True(context.HasScheduledActivity(_newExit)); + Assert.False(context.HasScheduledActivity(_payAction)); + + await CompleteScheduledActivityAsync(context, _newExit); + lifecycle.Add("source exit"); + Assert.True(context.HasScheduledActivity(_payAction)); + + await CompleteScheduledActivityAsync(context, _payAction); + lifecycle.Add("action"); + Assert.Equal("Paid", context.GetProperty(CurrentStateProperty)); + Assert.True(context.HasScheduledActivity(_paidEntry)); + + await CompleteScheduledActivityAsync(context, _paidEntry); + lifecycle.Add("target entry"); + Assert.True(context.HasScheduledActivity(_paidTrigger)); + + Assert.Equal(new[] { "source entry", "trigger", "source exit", "action", "target entry" }, lifecycle); + } + [Fact(DisplayName = "StateMachine treats missing transition condition as true")] public async Task MissingConditionAllowsTransition() { @@ -176,6 +210,47 @@ public class StateMachineTests Assert.DoesNotContain(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context); } + [Fact(DisplayName = "StateMachine completes after entering a terminal state")] + public async Task TerminalStateCompletesStateMachine() + { + var stateMachine = new StateMachineActivity + { + InitialState = "Done", + States = { new StateMachineState { Name = "Done" } } + }; + + var context = await ExecuteAsync(stateMachine); + + Assert.Equal("Done", context.GetProperty(CurrentStateProperty)); + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.Empty(context.WorkflowExecutionContext.Scheduler.List()); + } + + [Fact(DisplayName = "StateMachine accepts the first eligible triggerless transition in declaration order")] + public async Task TriggerlessTransitionsUseDeclarationOrder() + { + var stateMachine = new StateMachineActivity + { + InitialState = "Source", + States = + { + new StateMachineState { Name = "Source" }, + new StateMachineState { Name = "First" }, + new StateMachineState { Name = "Second" } + }, + Transitions = + { + new Transition { Name = "FirstTransition", From = "Source", To = "First", Condition = new(true) }, + new Transition { Name = "SecondTransition", From = "Source", To = "Second", Condition = new(true) } + } + }; + + var context = await ExecuteAsync(stateMachine); + + Assert.Equal("First", context.GetProperty(CurrentStateProperty)); + Assert.Equal(ActivityStatus.Completed, context.Status); + } + [Fact(DisplayName = "StateMachine self-transition executes exit, action and entry in order")] public async Task SelfTransitionExecutesExitActionAndEntryInOrder() { @@ -226,6 +301,31 @@ public class StateMachineTests Assert.DoesNotContain(cancelBookmark, context.WorkflowExecutionContext.Bookmarks); } + [Fact(DisplayName = "StateMachine cancels every distinct competing outbound trigger")] + public async Task AcceptedTransitionCancelsDistinctCompetingOutboundTriggers() + { + var thirdTrigger = new WriteLine("third trigger") { Id = "third-trigger" }; + _stateMachine.Transitions.Add(new Transition + { + Name = "CloseFromNew", + From = "New", + To = "Closed", + Trigger = thirdTrigger + }); + var context = await ExecuteAndEnterNewStateAsync(); + var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger); + var thirdTriggerContext = await CreateScheduledActivityContextAsync(context, thirdTrigger); + var cancelBookmark = cancelTriggerContext.CreateBookmark("cancel"); + var thirdBookmark = thirdTriggerContext.CreateBookmark("third"); + + await CompleteScheduledActivityAsync(context, _payTrigger); + + Assert.Equal(ActivityStatus.Canceled, cancelTriggerContext.Status); + Assert.Equal(ActivityStatus.Canceled, thirdTriggerContext.Status); + Assert.DoesNotContain(cancelBookmark, context.WorkflowExecutionContext.Bookmarks); + Assert.DoesNotContain(thirdBookmark, context.WorkflowExecutionContext.Bookmarks); + } + [Fact(DisplayName = "StateMachine rejects transitions that share a trigger instance")] public async Task SharedTriggerInstanceIsRejected() { @@ -348,6 +448,18 @@ public class StateMachineTests Assert.False(context.HasScheduledActivity(_paidEntry)); } + [Fact(DisplayName = "StateMachine ignores stale state exit completions")] + public async Task IgnoresStaleStateExitCompletions() + { + var context = await ExecuteAndEnterNewStateAsync(); + + await CompleteScheduledActivityAsync(context, _payTrigger); + context.SetProperty(CurrentStateProperty, "Paid"); + await CompleteScheduledActivityAsync(context, _newExit); + + Assert.False(context.HasScheduledActivity(_payAction)); + } + private async Task ExecuteAndEnterNewStateAsync() { var context = await ExecuteAsync(); From da5ccca2be0d9243ac93f6abf140927cb629024d Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 31 Aug 2026 03:30:17 +0200 Subject: [PATCH 4/5] Yield triggerless StateMachine cycles --- .../StateMachine/Activities/StateMachine.cs | 47 +++++++++++++++ .../StateMachine/StateMachineTests.cs | 57 +++++++++++++++++++ 2 files changed, 104 insertions(+) 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() { From 2787ff6bd0bc68d02c5bae598b0ff56a593c85d1 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 31 Aug 2026 04:51:34 +0200 Subject: [PATCH 5/5] Preserve StateMachine composite transition continuations --- .../006-state-machine-activity/quickstart.md | 45 ++++-- .../StateMachine/Activities/StateMachine.cs | 15 +- .../Services/WorkflowStateExtractor.cs | 14 +- .../State/ActivityWorkItemState.cs | 17 ++- .../StateMachine/StateMachineTests.cs | 140 +++++++++++++++++- .../Services/WorkflowStateExtractorTests.cs | 42 ++++++ 6 files changed, 249 insertions(+), 24 deletions(-) diff --git a/specs/006-state-machine-activity/quickstart.md b/specs/006-state-machine-activity/quickstart.md index 854b3442e..9b8380261 100644 --- a/specs/006-state-machine-activity/quickstart.md +++ b/specs/006-state-machine-activity/quickstart.md @@ -1,17 +1,40 @@ # Quickstart: State Machine Activity -1. Add a `StateMachine` with `InitialState = "NewOrder"`. -2. Add states named `NewOrder` and `Paid`. -3. Add an entry activity to `NewOrder`. -4. Add a transition from `NewOrder` to `Paid` with a trigger activity and optional action. -5. Execute the workflow. -6. Verify `NewOrder` entry runs, outbound transition triggers are scheduled, and the workflow remains active. -7. Complete the trigger and verify `CurrentState` becomes `Paid`. -8. Verify a false transition condition re-arms that transition trigger without canceling competing triggers. -9. Verify entering a state with no valid outbound transitions completes the state machine. +## Build a StateMachine -Validation command: +1. Add a `StateMachine` with a non-empty `InitialState`. +2. Add uniquely named states. Each state can have an optional `Entry` and `Exit` activity. +3. Add transitions in declaration order. Each transition names its source and target and can contain one optional `Trigger`, `Condition`, and `Action` activity. +4. Use `Sequence` when a slot must run more than one activity; Trigger and Action are scalar activity slots, not arrays. + +## Execution contract + +After the initial state's Entry completes, outbound transitions are considered in declaration order: + +1. Triggerless transitions are evaluated immediately. +2. A missing Condition is true. +3. A false triggerless Condition falls through to the next transition. If eventful alternatives exist, their triggers are scheduled. +4. When a transition is accepted, execution is source `Exit` → transition `Action` → target `Entry`. +5. Self-transitions run the same full Exit → Action → Entry lifecycle. +6. Entering a state with no outbound transitions completes the StateMachine. + +If every outbound transition is triggerless and false, the StateMachine remains active without scheduling a busy loop. Reachable triggerless cycles yield through Elsa's scheduler between transitions so they do not grow the synchronous call stack. + +## Durable and competing triggers + +- A winning transition cancels distinct competing trigger work before source Exit begins. +- If an eventful Trigger completes but its Condition is false, Elsa re-arms that rejected Trigger and preserves already-active competitors. This is an intentional Elsa durability deviation from WF4's cancel-all-and-reschedule behavior. +- Inline StateMachine JSON cannot preserve WF4 shared Trigger object identity. Elsa 3.8 rejects shared Trigger instances and duplicate trigger activity IDs explicitly instead of guessing identity from IDs. +- Continuation tags use declaration-order transition identity. Reordering transitions while an instance is suspended is a compatibility-sensitive definition change. + +These boundaries are part of the accepted StateMachine semantics ADR and are observable through validation and regression tests. + +## Validation + +Run the focused StateMachine tests: ```bash -dotnet test test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj --no-restore +dotnet test test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj --no-restore --filter FullyQualifiedName~StateMachine ``` + +The suite covers triggerless and eventful paths, missing and false conditions, lifecycle ordering, self-transitions, competing-trigger cancellation, identity rejection, persistence/resumption, and automatic-cycle scheduler yielding. 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 f7d713525..a452ef16f 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using Elsa.Expressions.Contracts; using Elsa.Extensions; +using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.StateMachine.Models; using Elsa.Workflows.Attributes; using Elsa.Workflows.Models; @@ -21,7 +22,7 @@ public class StateMachine : Activity private const string PhaseEntering = "Entering"; private const string PhaseContinuing = "Continuing"; private const string CurrentStateProperty = "CurrentState"; - private readonly StateMachineContinuation _automaticTransitionContinuation = new(); + private readonly Inline _automaticTransitionContinuation = new(); /// public StateMachine([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) @@ -124,7 +125,7 @@ public class StateMachine : Activity private async ValueTask OnTriggerCompletedAsync(ActivityCompletedContext context) { var targetContext = context.TargetContext; - var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string) ?? FindTransitionByTrigger(targetContext, context.ChildContext.Activity); + var transition = FindTransitionByKey(targetContext, GetCompletionTag(context)) ?? FindTransitionByTrigger(targetContext, context.ChildContext.Activity); if (transition == null || !IsCurrentSource(targetContext, transition) || FindState(transition.To) == null) return; @@ -168,7 +169,7 @@ public class StateMachine : Activity private async ValueTask OnTransitionActionCompletedAsync(ActivityCompletedContext context) { var targetContext = context.TargetContext; - var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string); + var transition = FindTransitionByKey(targetContext, GetCompletionTag(context)); if (transition == null || !IsCurrentSource(targetContext, transition)) return; @@ -192,7 +193,7 @@ public class StateMachine : Activity private async ValueTask OnStateExitCompletedAsync(ActivityCompletedContext context) { var targetContext = context.TargetContext; - var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string); + var transition = FindTransitionByKey(targetContext, GetCompletionTag(context)); if (transition == null || !IsCurrentSource(targetContext, transition)) return; @@ -417,7 +418,7 @@ public class StateMachine : Activity ? $"{transition.From}->{transition.To}" : transition.Name; - private sealed class StateMachineContinuation : CodeActivity - { - } + private static string? GetCompletionTag(ActivityCompletedContext context) => + context.TargetContext.Tag as string ?? context.ChildContext.Tag as string; + } diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs index 60ebe50f5..9275ed8c4 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs @@ -256,7 +256,16 @@ public class WorkflowStateExtractor(ILogger logger) : IW var variables = activityWorkItemState.Variables; var input = activityWorkItemState.Input; var tag = activityWorkItemState.Tag; - var workItem = new ActivityWorkItem(activity, ownerContext, tag, variables, existingActivityExecutionContext, input); + var workItem = new ActivityWorkItem( + activity, + ownerContext, + tag, + variables, + existingActivityExecutionContext, + input, + activityWorkItemState.SchedulingActivityExecutionId, + activityWorkItemState.SchedulingWorkflowInstanceId, + activityWorkItemState.SchedulingCallStackDepth); workflowExecutionContext.Scheduler.Schedule(workItem); } } @@ -329,6 +338,9 @@ public class WorkflowStateExtractor(ILogger logger) : IW Variables = x.Variables?.ToList(), ExistingActivityExecutionContextId = x.ExistingActivityExecutionContext?.Id, Input = x.Input, + SchedulingActivityExecutionId = x.SchedulingActivityExecutionId, + SchedulingWorkflowInstanceId = x.SchedulingWorkflowInstanceId, + SchedulingCallStackDepth = x.SchedulingCallStackDepth, }); state.ScheduledActivities = scheduledActivities.ToList(); diff --git a/src/modules/Elsa.Workflows.Core/State/ActivityWorkItemState.cs b/src/modules/Elsa.Workflows.Core/State/ActivityWorkItemState.cs index fc0c16871..4e02a2419 100644 --- a/src/modules/Elsa.Workflows.Core/State/ActivityWorkItemState.cs +++ b/src/modules/Elsa.Workflows.Core/State/ActivityWorkItemState.cs @@ -36,4 +36,19 @@ public class ActivityWorkItemState /// Optional input to pass to the activity. /// public IDictionary Input { get; set; } = new Dictionary(); -} \ No newline at end of file + + /// + /// The ID of the activity execution context that scheduled this work item. + /// + public string? SchedulingActivityExecutionId { get; set; } + + /// + /// The workflow instance ID of the workflow that scheduled this work item, if it crossed a workflow boundary. + /// + public string? SchedulingWorkflowInstanceId { get; set; } + + /// + /// The call stack depth of the activity execution context that scheduled this work item. + /// + public int? SchedulingCallStackDepth { get; set; } +} diff --git a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs index a45a8e4c5..877139b5b 100644 --- a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs @@ -1,9 +1,11 @@ using Elsa.Testing.Shared; +using Elsa.Mediator.Contracts; using Elsa.Workflows; using Elsa.Workflows.Activities.StateMachine.Models; using Elsa.Workflows.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; using StateMachineActivity = Elsa.Workflows.Activities.StateMachine.Activities.StateMachine; namespace Elsa.Activities.UnitTests.StateMachine; @@ -308,6 +310,106 @@ public class StateMachineTests Assert.Single(context.WorkflowExecutionContext.Scheduler.List()); } + [Fact(DisplayName = "StateMachine resumes an automatic cycle continuation after state persistence")] + public async Task AutomaticCycleContinuationSurvivesStateRoundTrip() + { + var stateMachine = new StateMachineActivity + { + InitialState = "A", + States = { new StateMachineState { Name = "A" } }, + Transitions = { new Transition { From = "A", To = "A" } } + }; + var fixture = CreatePersistenceFixture(stateMachine); + var context = await fixture.BuildAsync(); + context.Id = "state-machine-context"; + context.WorkflowExecutionContext.AddActivityExecutionContext(context); + + await fixture.ExecuteAsync(context); + + var sourceWorkflowContext = context.WorkflowExecutionContext; + var sourceContinuation = Assert.Single(sourceWorkflowContext.Scheduler.List()); + Assert.Equal(context.Id, sourceContinuation.SchedulingActivityExecutionId); + Assert.Single(sourceWorkflowContext.CompletionCallbacks); + + var extractor = sourceWorkflowContext.GetRequiredService(); + var state = extractor.Extract(sourceWorkflowContext); + var resumedWorkflowContext = await WorkflowExecutionContext.CreateAsync( + sourceWorkflowContext.ServiceProvider, + sourceWorkflowContext.WorkflowGraph, + state.Id, + CancellationToken.None); + + await extractor.ApplyAsync(resumedWorkflowContext, state); + + var resumedStateMachineContext = Assert.Single(resumedWorkflowContext.ActivityExecutionContexts, x => x.Activity == stateMachine); + var resumedContinuation = resumedWorkflowContext.Scheduler.Take(); + Assert.Single(state.CompletionCallbacks); + Assert.Equal(resumedStateMachineContext.Id, resumedContinuation.Owner?.Id); + Assert.Equal(resumedStateMachineContext.Id, resumedContinuation.SchedulingActivityExecutionId); + Assert.Contains(resumedWorkflowContext.CompletionCallbacks, x => x.Owner == resumedStateMachineContext && x.Child.Activity == resumedContinuation.Activity); + + var resumedChildContext = await resumedWorkflowContext.CreateActivityExecutionContextAsync(resumedContinuation.Activity, new ActivityInvocationOptions + { + Owner = resumedContinuation.Owner, + Tag = resumedContinuation.Tag, + SchedulingActivityExecutionId = resumedContinuation.SchedulingActivityExecutionId + }); + resumedChildContext.TransitionTo(ActivityStatus.Running); + resumedWorkflowContext.AddActivityExecutionContext(resumedChildContext); + await resumedContinuation.Activity.ExecuteAsync(resumedChildContext); + + Assert.Equal("A", resumedStateMachineContext.GetProperty(CurrentStateProperty)); + Assert.Equal(ActivityStatus.Running, resumedStateMachineContext.Status); + Assert.Single(resumedWorkflowContext.Scheduler.List()); + } + + [Fact(DisplayName = "StateMachine completes a triggerless transition with a composite action")] + public async Task TriggerlessTransitionWithCompositeActionCompletes() + { + var stateMachine = new StateMachineActivity + { + InitialState = "Source", + States = + { + new StateMachineState + { + Name = "Source", + Exit = new WriteLine("exit") { Id = "source-exit" } + }, + new StateMachineState { Name = "Target" } + }, + Transitions = + { + new Transition + { + From = "Source", + To = "Target", + Condition = new(true), + Action = new Sequence + { + Activities = + { + new WriteLine("first action") { Id = "first-action" }, + new WriteLine("second action") { Id = "second-action" } + } + } + } + } + }; + var fixture = CreatePersistenceFixture(stateMachine); + var context = await fixture.BuildAsync(); + context.Id = "state-machine-context"; + context.WorkflowExecutionContext.AddActivityExecutionContext(context); + + await fixture.ExecuteAsync(context); + + while (context.WorkflowExecutionContext.Scheduler.HasAny) + await ExecuteNextScheduledActivityAsync(context.WorkflowExecutionContext); + + Assert.Equal("Target", context.GetProperty(CurrentStateProperty)); + Assert.Equal(ActivityStatus.Completed, context.Status); + } + [Fact(DisplayName = "StateMachine self-transition executes exit, action and entry in order")] public async Task SelfTransitionExecutesExitActionAndEntryInOrder() { @@ -524,13 +626,23 @@ public class StateMachineTests return context; } - private Task ExecuteAsync(StateMachineActivity? stateMachine = null) => new ActivityTestFixture(stateMachine ?? _stateMachine) + private static ActivityTestFixture CreateFixture(StateMachineActivity stateMachine) => new ActivityTestFixture(stateMachine) .ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(); - }) - .ExecuteAsync(); + }); + + private static ActivityTestFixture CreatePersistenceFixture(StateMachineActivity stateMachine) => CreateFixture(stateMachine) + .ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + }); + + private Task ExecuteAsync(StateMachineActivity? stateMachine = null) => CreateFixture(stateMachine ?? _stateMachine).ExecuteAsync(); private static async Task CompleteScheduledActivityAsync(ActivityExecutionContext ownerContext, IActivity activity) { @@ -538,7 +650,8 @@ public class StateMachineTests var callback = PopCallback(ownerContext, activity); Assert.NotNull(callback?.CompletionCallback); - await callback!.CompletionCallback!(new ActivityCompletedContext(ownerContext, childContext)); + ownerContext.Tag = callback!.Tag; + await callback.CompletionCallback!(new ActivityCompletedContext(ownerContext, childContext)); } private static async Task CreateScheduledActivityContextAsync(ActivityExecutionContext ownerContext, IActivity activity) @@ -554,6 +667,25 @@ public class StateMachineTests return childContext; } + private static async Task ExecuteNextScheduledActivityAsync(WorkflowExecutionContext workflowContext) + { + var workItem = workflowContext.Scheduler.Take(); + var childContext = await workflowContext.CreateActivityExecutionContextAsync(workItem.Activity, new ActivityInvocationOptions + { + Owner = workItem.Owner, + ExistingActivityExecutionContext = workItem.ExistingActivityExecutionContext, + Tag = workItem.Tag, + Variables = workItem.Variables, + Input = workItem.Input, + SchedulingActivityExecutionId = workItem.SchedulingActivityExecutionId, + SchedulingWorkflowInstanceId = workItem.SchedulingWorkflowInstanceId, + SchedulingCallStackDepth = workItem.SchedulingCallStackDepth + }); + childContext.TransitionTo(ActivityStatus.Running); + workflowContext.AddActivityExecutionContext(childContext); + await childContext.Activity.ExecuteAsync(childContext); + } + private static int CountScheduledActivities(ActivityExecutionContext context, IActivity activity) => context.WorkflowExecutionContext.Scheduler.List().Count(x => x.Activity == activity); diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/WorkflowStateExtractorTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/WorkflowStateExtractorTests.cs index 761c0655c..9bb4b57bd 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Services/WorkflowStateExtractorTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/WorkflowStateExtractorTests.cs @@ -1,10 +1,12 @@ using Elsa.Common; using Elsa.Testing.Shared; using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; using Elsa.Workflows.Options; using Elsa.Workflows.Services; using Elsa.Workflows.State; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Xunit; @@ -12,6 +14,46 @@ namespace Elsa.Workflows.Core.UnitTests.Services; public class WorkflowStateExtractorTests { + [Fact] + public async Task Extract_And_Apply_PreservesScheduledActivityMetadata() + { + var root = new WriteLine("root"); + var fixture = new ActivityTestFixture(root).ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + }); + var context = await fixture.BuildAsync(); + context.Id = "owner-context"; + context.WorkflowExecutionContext.AddActivityExecutionContext(context); + var scheduledWorkItem = new ActivityWorkItem( + root, + context, + tag: "scheduled", + schedulingActivityExecutionId: "predecessor", + schedulingWorkflowInstanceId: "parent-workflow", + schedulingCallStackDepth: 3); + context.WorkflowExecutionContext.Scheduler.Schedule(scheduledWorkItem); + + var sourceWorkflowContext = context.WorkflowExecutionContext; + var extractor = sourceWorkflowContext.GetRequiredService(); + var state = extractor.Extract(sourceWorkflowContext); + var resumedWorkflowContext = await WorkflowExecutionContext.CreateAsync( + sourceWorkflowContext.ServiceProvider, + sourceWorkflowContext.WorkflowGraph, + state.Id, + CancellationToken.None); + + await extractor.ApplyAsync(resumedWorkflowContext, state); + + var restoredWorkItem = Assert.Single(resumedWorkflowContext.Scheduler.List()); + Assert.Equal("owner-context", restoredWorkItem.Owner?.Id); + Assert.Equal("scheduled", restoredWorkItem.Tag); + Assert.Equal("predecessor", restoredWorkItem.SchedulingActivityExecutionId); + Assert.Equal("parent-workflow", restoredWorkItem.SchedulingWorkflowInstanceId); + Assert.Equal(3, restoredWorkItem.SchedulingCallStackDepth); + } + [Fact] public async Task Extract_And_Apply_PreservesCallStackDepth() {