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()
{