Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs
T
Peter IbekweandGitHub 38de991481 .NET: Fix RequestInfoEvent lost when resuming workflow from checkpoint (#4955)
* Fix RequestInfoEvent lost when resuming workflow from checkpoint

* Fix streaming run double disposal in tests and lockstep republishing before Started event is emitted.

* Fix bug to remove messages after sending to avoid losing messages on send failure.

* Fix declarative test harness
2026-04-01 15:38:48 +00:00

323 lines
14 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal class WorkflowHostExecutor : Executor, IAsyncDisposable
{
private readonly string _sessionId;
private readonly Workflow _workflow;
private readonly ProtocolDescriptor _workflowProtocol;
private readonly object _ownershipToken;
private InProcessRunner? _activeRunner;
private InMemoryCheckpointManager? _checkpointManager;
private readonly ExecutorOptions _options;
private readonly ConcurrentDictionary<string, RequestPortInfo> _pendingResponsePorts = new(StringComparer.Ordinal);
private ISuperStepJoinContext? _joinContext;
private string? _joinId;
private StreamingRun? _run;
[MemberNotNullWhen(true, nameof(_checkpointManager))]
private bool WithCheckpointing => this._checkpointManager != null;
public WorkflowHostExecutor(string id, Workflow workflow, ProtocolDescriptor workflowProtocol, string sessionId, object ownershipToken, ExecutorOptions? options = null) : base(id, options)
{
this._options = options ?? new();
this._sessionId = Throw.IfNull(sessionId);
this._ownershipToken = Throw.IfNull(ownershipToken);
this._workflow = Throw.IfNull(workflow);
this._workflowProtocol = Throw.IfNull(workflowProtocol);
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
if (this._options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder = protocolBuilder.YieldsOutputTypes(this._workflowProtocol.Yields);
}
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddCatchAll(this.QueueExternalMessageAsync))
.SendsMessageTypes(this._workflowProtocol.Yields);
}
private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context, CancellationToken cancellationToken)
{
if (portableValue.Is(out ExternalResponse? response))
{
response = this.CheckAndUnqualifyResponse(response);
await this.EnsureRunSendMessageAsync(response, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
InProcessRunner runner = await this.EnsureRunnerAsync().ConfigureAwait(false);
IEnumerable<Type> validInputTypes = await runner.RunContext.GetStartingExecutorInputTypesAsync(cancellationToken).ConfigureAwait(false);
foreach (Type candidateType in validInputTypes)
{
if (portableValue.IsType(candidateType, out object? message))
{
await this.EnsureRunSendMessageAsync(message, candidateType, cancellationToken: cancellationToken).ConfigureAwait(false);
return;
}
}
}
}
private ISuperStepJoinContext JoinContext => Throw.IfNull(this._joinContext, "Must attach to a join context before starting the run.");
internal async ValueTask<InProcessRunner> EnsureRunnerAsync()
{
if (this._activeRunner == null)
{
if (this.JoinContext.IsCheckpointingEnabled)
{
// Use a seprate in-memory checkpoint manager for scoping purposes. We do not need to worry about
// serialization because we will be relying on the parent workflow's checkpoint manager to do that,
// if needed. For our purposes, all we need is to keep a faithful representation of the checkpointed
// objects so we can emit them back to the parent workflow on checkpoint creation.
this._checkpointManager ??= new InMemoryCheckpointManager();
}
this._activeRunner = InProcessRunner.CreateSubworkflowRunner(this._workflow,
this._checkpointManager,
this._sessionId,
this._ownershipToken,
this.JoinContext.ConcurrentRunsEnabled);
}
return this._activeRunner;
}
internal async ValueTask<StreamingRun> EnsureRunSendMessageAsync(object? incomingMessage = null, Type? incomingMessageType = null, bool resume = false, CancellationToken cancellationToken = default)
{
Debug.Assert(this._joinContext != null, "Must attach to a join context before starting the run.");
if (this._run != null)
{
if (incomingMessage != null)
{
await this._run.TrySendMessageUntypedAsync(incomingMessage, incomingMessageType ?? incomingMessage.GetType()).ConfigureAwait(false);
}
return this._run;
}
InProcessRunner activeRunner = await this.EnsureRunnerAsync().ConfigureAwait(false);
AsyncRunHandle runHandle;
if (this.WithCheckpointing)
{
if (resume)
{
// Attempting to resume from checkpoint
if (!this._checkpointManager.TryGetLastCheckpoint(this._sessionId, out CheckpointInfo? lastCheckpoint))
{
throw new InvalidOperationException("No checkpoints available to resume from.");
}
runHandle = await activeRunner.ResumeStreamAsync(ExecutionMode.Subworkflow, lastCheckpoint!, cancellationToken)
.ConfigureAwait(false);
if (incomingMessage != null)
{
await runHandle.EnqueueMessageUntypedAsync(incomingMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
else if (incomingMessage != null)
{
runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellationToken)
.ConfigureAwait(false);
await runHandle.EnqueueMessageUntypedAsync(incomingMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
throw new InvalidOperationException("Cannot start a checkpointed workflow run without an incoming message or resume flag.");
}
}
else
{
runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellationToken).ConfigureAwait(false);
await runHandle.EnqueueMessageUntypedAsync(Throw.IfNull(incomingMessage), cancellationToken: cancellationToken).ConfigureAwait(false);
}
this._run = new(runHandle);
this._joinId = await this._joinContext.AttachSuperstepAsync(activeRunner, cancellationToken).ConfigureAwait(false);
activeRunner.OutgoingEvents.EventRaised += this.ForwardWorkflowEventAsync;
return this._run;
}
private ExternalResponse? CheckAndUnqualifyResponse([DisallowNull] ExternalResponse response)
{
if (this._pendingResponsePorts.TryRemove(response.RequestId, out RequestPortInfo? originalPort))
{
return response with { PortInfo = originalPort };
}
if (!Throw.IfNull(response).PortInfo.PortId.StartsWith($"{this.Id}.", StringComparison.Ordinal))
{
return null;
}
RequestPortInfo unqualifiedPort = response.PortInfo with { PortId = response.PortInfo.PortId.Substring(this.Id.Length + 1) };
return response with { PortInfo = unqualifiedPort };
}
private ExternalRequest QualifyRequestPortId(ExternalRequest internalRequest)
{
RequestPortInfo requestPort = internalRequest.PortInfo with { PortId = $"{this.Id}.{internalRequest.PortInfo.PortId}" };
return internalRequest with { PortInfo = requestPort };
}
private async ValueTask ForwardWorkflowEventAsync(object? sender, WorkflowEvent evt)
{
// Note that we are explicitly not using the checked JoinContext property here, because this is an async callback.
try
{
Task resultTask = Task.CompletedTask;
switch (evt)
{
case WorkflowStartedEvent:
case SuperStepStartedEvent:
case SuperStepCompletedEvent:
// These events are internal to the subworkflow and do not need to be forwarded.
break;
case RequestInfoEvent requestInfoEvt:
ExternalRequest request = requestInfoEvt.Request;
this._pendingResponsePorts[request.RequestId] = request.PortInfo;
resultTask = this._joinContext?.SendMessageAsync(this.Id, this.QualifyRequestPortId(request)).AsTask() ?? Task.CompletedTask;
break;
case WorkflowErrorEvent errorEvent:
resultTask = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowErrorEvent(this.Id, errorEvent.Data as Exception)).AsTask() ?? Task.CompletedTask;
break;
case WorkflowOutputEvent outputEvent:
if (this._joinContext != null &&
this._options.AutoSendMessageHandlerResultObject
&& outputEvent.Data != null)
{
resultTask = this._joinContext.SendMessageAsync(this.Id, outputEvent.Data).AsTask();
}
if (this._joinContext != null &&
this._options.AutoYieldOutputHandlerResultObject
&& outputEvent.Data != null)
{
resultTask = this._joinContext.YieldOutputAsync(this.Id, outputEvent.Data).AsTask();
}
break;
case RequestHaltEvent requestHaltEvent:
resultTask = this._joinContext?.ForwardWorkflowEventAsync(new RequestHaltEvent()).AsTask() ?? Task.CompletedTask;
break;
case WorkflowWarningEvent warningEvent:
if (warningEvent.Data is string warningMessage)
{
resultTask = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowWarningEvent(this.Id, warningMessage)).AsTask() ?? Task.CompletedTask;
}
break;
default:
resultTask = this._joinContext?.ForwardWorkflowEventAsync(evt).AsTask() ?? Task.CompletedTask;
break;
}
await resultTask.ConfigureAwait(false);
}
catch (Exception ex)
{
try
{
_ = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowErrorEvent(this.Id, ex)).AsTask();
}
catch
{ }
}
}
internal async ValueTask AttachSuperStepContextAsync(ISuperStepJoinContext joinContext)
{
this._joinContext = Throw.IfNull(joinContext);
}
private const string CheckpointManagerStateKey = nameof(CheckpointManager);
private const string PendingResponsePortsStateKey = nameof(PendingResponsePortsStateKey);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await context.QueueStateUpdateAsync(CheckpointManagerStateKey, this._checkpointManager, cancellationToken: cancellationToken).ConfigureAwait(false);
await context.QueueStateUpdateAsync(PendingResponsePortsStateKey,
new Dictionary<string, RequestPortInfo>(this._pendingResponsePorts, StringComparer.Ordinal),
cancellationToken: cancellationToken).ConfigureAwait(false);
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
InMemoryCheckpointManager manager = await context.ReadStateAsync<InMemoryCheckpointManager>(CheckpointManagerStateKey, cancellationToken: cancellationToken).ConfigureAwait(false) ?? new();
if (this._checkpointManager == manager)
{
// We are restoring in the context of the same run; not need to rebuild the entire execution stack.
}
else
{
this._checkpointManager = manager;
await this.ResetAsync().ConfigureAwait(false);
}
this._pendingResponsePorts.Clear();
Dictionary<string, RequestPortInfo> pendingResponsePorts =
await context.ReadStateAsync<Dictionary<string, RequestPortInfo>>(PendingResponsePortsStateKey, cancellationToken: cancellationToken)
.ConfigureAwait(false) ?? [];
foreach (KeyValuePair<string, RequestPortInfo> pendingResponsePort in pendingResponsePorts)
{
this._pendingResponsePorts[pendingResponsePort.Key] = pendingResponsePort.Value;
}
await this.EnsureRunSendMessageAsync(resume: true, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private async ValueTask ResetAsync()
{
if (this._run != null)
{
await this._run.DisposeAsync().ConfigureAwait(false);
this._run = null;
}
this._pendingResponsePorts.Clear();
if (this._activeRunner != null)
{
this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync;
await this._activeRunner.RequestEndRunAsync().ConfigureAwait(false);
this._activeRunner = null;
}
if (this._joinContext != null && this._joinId != null)
{
await this._joinContext.DetachSuperstepAsync(this._joinId).ConfigureAwait(false);
this._joinId = null;
}
}
public ValueTask DisposeAsync() => this.ResetAsync();
}