607 lines
20 KiB
C#
607 lines
20 KiB
C#
using Appccelerate.StateMachine;
|
|
using Appccelerate.StateMachine.Machine;
|
|
using RobotNet10.Script;
|
|
using RobotNet10.ScriptEngine.Shared;
|
|
|
|
namespace RobotNet10.ScriptEngine.Models;
|
|
|
|
/// <summary>
|
|
/// Represents a mission instance with state machine management.
|
|
/// </summary>
|
|
public class ScriptMission : IDisposable
|
|
{
|
|
private readonly PassiveStateMachine<ScriptMissionState, MissionTrigger> _stateMachine;
|
|
private readonly ScriptMissionModel _model;
|
|
private readonly ScriptGlobals _globals;
|
|
private readonly ILogger _logger;
|
|
private readonly CancellationTokenSource _internalCts;
|
|
private CancellationTokenSource? _executionCts;
|
|
private Task? _executionTask;
|
|
private bool _isPaused;
|
|
private bool _isCanceling;
|
|
private bool _disposed;
|
|
private Exception? _lastError;
|
|
private readonly Lock _lockObject = new();
|
|
private ScriptMissionState _currentState;
|
|
private int _currentScore;
|
|
|
|
/// <summary>
|
|
/// Mission triggers for state machine transitions.
|
|
/// </summary>
|
|
public enum MissionTrigger
|
|
{
|
|
Start,
|
|
Cancel,
|
|
Pause,
|
|
Resume,
|
|
CompleteCanceling,
|
|
CompletePausing,
|
|
CompleteResuming,
|
|
CompleteRunning,
|
|
ErrorOccurred,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the unique identifier of the mission instance.
|
|
/// </summary>
|
|
public Guid Id { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the name of the mission.
|
|
/// </summary>
|
|
public string Name => _model.Name;
|
|
|
|
/// <summary>
|
|
/// Gets the total score for progress tracking.
|
|
/// </summary>
|
|
public int TotalScore => _model.TotalScore;
|
|
|
|
/// <summary>
|
|
/// Gets the current score.
|
|
/// </summary>
|
|
public int CurrentScore => _currentScore;
|
|
|
|
/// <summary>
|
|
/// Gets the current state of the mission.
|
|
/// </summary>
|
|
public ScriptMissionState State => _currentState;
|
|
|
|
/// <summary>
|
|
/// Gets the last error that occurred during mission execution.
|
|
/// </summary>
|
|
public Exception? LastError => _lastError;
|
|
|
|
/// <summary>
|
|
/// Gets the log message from mission execution.
|
|
/// </summary>
|
|
public string LogMessage => _logger.GetLog();
|
|
|
|
/// <summary>
|
|
/// Gets the log message from ILogger.
|
|
/// </summary>
|
|
public string GetLog()
|
|
{
|
|
return _logger.GetLog();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets whether the mission is currently executing.
|
|
/// </summary>
|
|
public bool IsExecuting => _executionTask != null && !_executionTask.IsCompleted;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the ScriptMission class.
|
|
/// </summary>
|
|
/// <param name="id">The unique identifier for this mission instance.</param>
|
|
/// <param name="model">The mission model containing mission metadata and runner.</param>
|
|
/// <param name="globals">The script globals dictionary.</param>
|
|
public ScriptMission(
|
|
Guid id,
|
|
ScriptMissionModel model,
|
|
ScriptGlobals globals)
|
|
{
|
|
Id = id;
|
|
_model = model ?? throw new ArgumentNullException(nameof(model));
|
|
_globals = globals ?? throw new ArgumentNullException(nameof(globals));
|
|
_internalCts = new CancellationTokenSource();
|
|
|
|
// Get logger from globals.ScriptRobotNet
|
|
if (_globals.RobotNet.TryGetValue("get_Logger", out object? getLogger) &&
|
|
getLogger is Func<ILogger> getLoggerFunc)
|
|
{
|
|
_logger = getLoggerFunc.Invoke();
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException($"Failed to get Logger from ScriptRobotNet globals for mission '{model.Name}'");
|
|
}
|
|
|
|
var builder = new StateMachineDefinitionBuilder<ScriptMissionState, MissionTrigger>();
|
|
|
|
// Configure state machine transitions according to StateMachine_Design.md
|
|
ConfigureStateMachine(builder);
|
|
|
|
_stateMachine = builder
|
|
.WithInitialState(ScriptMissionState.Idle)
|
|
.Build()
|
|
.CreatePassiveStateMachine();
|
|
|
|
_currentState = ScriptMissionState.Idle;
|
|
_stateMachine.Start();
|
|
}
|
|
|
|
private void ConfigureStateMachine(StateMachineDefinitionBuilder<ScriptMissionState, MissionTrigger> builder)
|
|
{
|
|
// Idle state
|
|
builder.In(ScriptMissionState.Idle)
|
|
.On(MissionTrigger.Start)
|
|
.Goto(ScriptMissionState.Running);
|
|
|
|
// Running state
|
|
builder.In(ScriptMissionState.Running)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Running; OnEnterRunning(); })
|
|
.On(MissionTrigger.Cancel)
|
|
.Goto(ScriptMissionState.Canceling)
|
|
.On(MissionTrigger.Pause)
|
|
.Goto(ScriptMissionState.Pausing)
|
|
.On(MissionTrigger.CompleteRunning)
|
|
.Goto(ScriptMissionState.Completed)
|
|
.On(MissionTrigger.ErrorOccurred)
|
|
.Goto(ScriptMissionState.Error)
|
|
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
|
|
|
|
// Canceling state
|
|
builder.In(ScriptMissionState.Canceling)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Canceling; OnEnterCanceling(); })
|
|
.On(MissionTrigger.CompleteCanceling)
|
|
.Goto(ScriptMissionState.Canceled)
|
|
.On(MissionTrigger.ErrorOccurred)
|
|
.Goto(ScriptMissionState.Error)
|
|
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
|
|
|
|
// Pausing state
|
|
builder.In(ScriptMissionState.Pausing)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Pausing; OnEnterPausing(); })
|
|
.On(MissionTrigger.CompletePausing)
|
|
.Goto(ScriptMissionState.Paused)
|
|
.On(MissionTrigger.ErrorOccurred)
|
|
.Goto(ScriptMissionState.Error)
|
|
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
|
|
|
|
// Paused state
|
|
builder.In(ScriptMissionState.Paused)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Paused; OnEnterPaused(); })
|
|
.On(MissionTrigger.Resume)
|
|
.Goto(ScriptMissionState.Resuming)
|
|
.On(MissionTrigger.Cancel)
|
|
.Goto(ScriptMissionState.Canceling);
|
|
|
|
// Resuming state
|
|
builder.In(ScriptMissionState.Resuming)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Resuming; OnEnterResuming(); })
|
|
.On(MissionTrigger.CompleteResuming)
|
|
.Goto(ScriptMissionState.Running)
|
|
.On(MissionTrigger.ErrorOccurred)
|
|
.Goto(ScriptMissionState.Error)
|
|
.Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
|
|
|
|
// Canceled state (terminal)
|
|
builder.In(ScriptMissionState.Canceled)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Canceled; OnEnterCanceled(); });
|
|
|
|
// Completed state (terminal)
|
|
builder.In(ScriptMissionState.Completed)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Completed; OnEnterCompleted(); });
|
|
|
|
// Error state (terminal)
|
|
builder.In(ScriptMissionState.Error)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptMissionState.Error; OnEnterError(); });
|
|
}
|
|
|
|
#region State Machine Event Handlers
|
|
|
|
private void OnEnterRunning()
|
|
{
|
|
lock (_lockObject)
|
|
{
|
|
if (_executionTask == null || _executionTask.IsCompleted)
|
|
{
|
|
// Create new execution task
|
|
_executionCts = CancellationTokenSource.CreateLinkedTokenSource(_internalCts.Token);
|
|
_isPaused = false;
|
|
_isCanceling = false;
|
|
_currentScore = 0;
|
|
|
|
// Update CancellationToken parameters in MissionParameters to link with mission's cancellation token
|
|
// This allows script runner to receive cancellation when Cancel() is called
|
|
foreach (var paramModel in _model.Parameters)
|
|
{
|
|
if (paramModel.Type == typeof(CancellationToken))
|
|
{
|
|
// Link CancellationToken parameter with mission's internal cancellation token
|
|
_globals.MissionParameters[paramModel.Name] = _executionCts.Token;
|
|
}
|
|
}
|
|
|
|
// Use standard thread pool
|
|
_executionTask = Task.Run(() => ExecuteMissionAsync(_executionCts.Token), _executionCts.Token);
|
|
_logger.LogInfo($"Mission '{_model.Name}' started running.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnEnterCanceling()
|
|
{
|
|
lock (_lockObject)
|
|
{
|
|
_isCanceling = true;
|
|
_executionCts?.Cancel();
|
|
_logger.LogInfo($"Mission '{_model.Name}' is canceling...");
|
|
}
|
|
|
|
Task.Run(async () =>
|
|
{
|
|
// Wait for execution to complete cancellation
|
|
if (_executionTask != null)
|
|
{
|
|
try
|
|
{
|
|
await _executionTask;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected when canceling
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Mission '{_model.Name}' cancellation error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
_stateMachine.Fire(MissionTrigger.CompleteCanceling);
|
|
});
|
|
}
|
|
|
|
private void OnEnterPausing()
|
|
{
|
|
lock (_lockObject)
|
|
{
|
|
_isPaused = true;
|
|
_logger.LogInfo($"Mission '{_model.Name}' is pausing...");
|
|
}
|
|
|
|
Task.Run(async () =>
|
|
{
|
|
// Wait for current step to complete (check in NextStepHandler)
|
|
await Task.Delay(100); // Small delay to allow current step to check pause flag
|
|
_stateMachine.Fire(MissionTrigger.CompletePausing);
|
|
});
|
|
}
|
|
|
|
private void OnEnterPaused()
|
|
{
|
|
_logger.LogInfo($"Mission '{_model.Name}' is paused.");
|
|
}
|
|
|
|
private void OnEnterResuming()
|
|
{
|
|
lock (_lockObject)
|
|
{
|
|
_isPaused = false;
|
|
_logger.LogInfo($"Mission '{_model.Name}' is resuming...");
|
|
}
|
|
|
|
Task.Run(async () =>
|
|
{
|
|
// Small delay to ensure state transition
|
|
await Task.Delay(50);
|
|
_stateMachine.Fire(MissionTrigger.CompleteResuming);
|
|
});
|
|
}
|
|
|
|
private void OnEnterCanceled()
|
|
{
|
|
_logger.LogInfo($"Mission '{_model.Name}' was canceled. Final score: {_currentScore}/{TotalScore}");
|
|
}
|
|
|
|
private void OnEnterCompleted()
|
|
{
|
|
_logger.LogInfo($"Mission '{_model.Name}' completed successfully. Final score: {_currentScore}/{TotalScore}");
|
|
}
|
|
|
|
private void OnEnterError()
|
|
{
|
|
_logger.LogError($"Mission '{_model.Name}' entered error state. Last error: {_lastError?.Message}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Public Control Methods
|
|
|
|
/// <summary>
|
|
/// Starts the mission (transitions from Idle to Running).
|
|
/// </summary>
|
|
public void Start()
|
|
{
|
|
try
|
|
{
|
|
_stateMachine.Fire(MissionTrigger.Start);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to start mission '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancels the mission (transitions from Running/Paused to Canceling → Canceled).
|
|
/// </summary>
|
|
public void Cancel(string reason)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogWarning($"Cancellation requested with reason: {reason}");
|
|
_stateMachine.Fire(MissionTrigger.Cancel);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to cancel mission '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pauses the mission (transitions from Running to Pausing → Paused).
|
|
/// </summary>
|
|
public void Pause()
|
|
{
|
|
try
|
|
{
|
|
_stateMachine.Fire(MissionTrigger.Pause);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to pause mission '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resumes the mission (transitions from Paused to Resuming → Running).
|
|
/// </summary>
|
|
public void Resume()
|
|
{
|
|
try
|
|
{
|
|
_stateMachine.Fire(MissionTrigger.Resume);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to resume mission '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for the mission to reach a terminal state (Completed, Canceled, or Error).
|
|
/// </summary>
|
|
public void WaitForStop(int timeoutMs = 10000)
|
|
{
|
|
var startTime = DateTime.UtcNow;
|
|
// Only wait if mission is actually executing - if not executing, state machine issue, proceed anyway
|
|
while (IsExecuting &&
|
|
_currentState != ScriptMissionState.Completed &&
|
|
_currentState != ScriptMissionState.Canceled &&
|
|
_currentState != ScriptMissionState.Error)
|
|
{
|
|
if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs)
|
|
{
|
|
// Timeout - mission is still executing (ScriptRunner blocking)
|
|
_logger.LogWarning($"Mission '{_model.Name}' is still executing after {timeoutMs}ms timeout. ScriptRunner may be blocking.");
|
|
break;
|
|
}
|
|
Thread.Sleep(50); // Check every 50ms
|
|
}
|
|
|
|
// If mission is not executing, it's effectively stopped (even if state machine didn't transition)
|
|
if (!IsExecuting)
|
|
{
|
|
return; // Mission is not executing, proceed
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Mission Execution
|
|
|
|
private async Task ExecuteMissionAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
// Execute the script runner with globals
|
|
// The CancellationToken parameter in MissionParameters is already linked to _executionCts.Token
|
|
// so the script runner will receive cancellation when Cancel() is called
|
|
var result = await _model.Runner(_globals, cancellationToken);
|
|
|
|
if (result is IAsyncEnumerable<MissionStatus> statusEnumerable)
|
|
{
|
|
var enumerator = statusEnumerable.GetAsyncEnumerator(cancellationToken);
|
|
try
|
|
{
|
|
while (await enumerator.MoveNextAsync())
|
|
{
|
|
var status = enumerator.Current;
|
|
|
|
// Update score and log message
|
|
_currentScore += status.Score;
|
|
|
|
var progress = TotalScore > 0 ? 100.0 * _currentScore / TotalScore : 0.0;
|
|
_logger.LogInfo($"Mission '{_model.Name}' progress: {progress:0.##}% - {status.Message}");
|
|
|
|
// Check for pause/resume/cancel/stop via NextStepHandler
|
|
if (!await NextStepHandlerAsync(cancellationToken))
|
|
{
|
|
// Mission was canceled or stopped
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await enumerator.DisposeAsync();
|
|
}
|
|
|
|
// Mission completed successfully
|
|
_stateMachine.Fire(MissionTrigger.CompleteRunning);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogError($"Mission '{_model.Name}' runner did not return IAsyncEnumerable<MissionStatus>.");
|
|
_lastError = new InvalidOperationException("Mission runner must return IAsyncEnumerable<MissionStatus>");
|
|
_stateMachine.Fire(MissionTrigger.ErrorOccurred);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected when canceling
|
|
if (_isCanceling)
|
|
{
|
|
// Cancellation was requested, state machine will handle transition
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
_lastError = new OperationCanceledException("Mission execution was canceled");
|
|
_stateMachine.Fire(MissionTrigger.ErrorOccurred);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_lastError = ex;
|
|
_logger.LogError($"Mission '{_model.Name}' execution error: {ex.Message}");
|
|
_stateMachine.Fire(MissionTrigger.ErrorOccurred);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles state transitions during mission execution (pause/resume/cancel/stop).
|
|
/// Returns false if execution should stop, true if execution should continue.
|
|
/// </summary>
|
|
private async Task<bool> NextStepHandlerAsync(CancellationToken cancellationToken)
|
|
{
|
|
// Check for cancellation
|
|
if (cancellationToken.IsCancellationRequested || _isCanceling)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Check for pause - wait until resumed or canceled
|
|
while (_isPaused && !cancellationToken.IsCancellationRequested && !_isCanceling)
|
|
{
|
|
// Use async delay to avoid blocking
|
|
await Task.Delay(1000, cancellationToken);
|
|
}
|
|
|
|
// Check again after pause
|
|
if (cancellationToken.IsCancellationRequested || _isCanceling)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region IDisposable
|
|
|
|
/// <summary>
|
|
/// Disposes the mission. Can be called from any state.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_lockObject)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
_disposed = true;
|
|
}
|
|
|
|
// Cancel execution if running
|
|
try
|
|
{
|
|
if (_currentState == ScriptMissionState.Running ||
|
|
_currentState == ScriptMissionState.Paused ||
|
|
_currentState == ScriptMissionState.Pausing ||
|
|
_currentState == ScriptMissionState.Resuming)
|
|
{
|
|
_internalCts.Cancel();
|
|
_executionCts?.Cancel();
|
|
|
|
// Wait a bit for cancellation to complete
|
|
var cancelTimeout = TimeSpan.FromSeconds(2);
|
|
var startTime = DateTime.UtcNow;
|
|
while ((_currentState == ScriptMissionState.Running ||
|
|
_currentState == ScriptMissionState.Paused ||
|
|
_currentState == ScriptMissionState.Pausing ||
|
|
_currentState == ScriptMissionState.Resuming ||
|
|
_currentState == ScriptMissionState.Canceling) &&
|
|
(DateTime.UtcNow - startTime) < cancelTimeout)
|
|
{
|
|
Thread.Sleep(50);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors during cancellation
|
|
}
|
|
|
|
// Wait for execution task to complete
|
|
try
|
|
{
|
|
if (_executionTask != null && !_executionTask.IsCompleted)
|
|
{
|
|
_executionTask.Wait(TimeSpan.FromSeconds(5));
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors
|
|
}
|
|
|
|
// Dispose resources
|
|
try
|
|
{
|
|
_internalCts?.Dispose();
|
|
_executionCts?.Dispose();
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors
|
|
}
|
|
|
|
// Stop state machine
|
|
try
|
|
{
|
|
_stateMachine.Stop();
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors
|
|
}
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
#endregion
|
|
}
|