Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs
T
425f27f989 .NET: Fixing issue where OpenTelemetry span is never exported in .NET in-process workflow execution (#4196)
* 1. Add reproduction test for issue #4155: workflow.run Activity never stopped in streaming OffThread path

The WorkflowRunActivity_IsStopped_Streaming_OffThread test demonstrates that
the workflow.run OpenTelemetry Activity created in StreamingRunEventStream.RunLoopAsync
is started but never stopped when using the OffThread/Default streaming execution.
The background run loop keeps running after event consumption completes, so the
using Activity? declaration never disposes until explicit StopAsync() is called.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

2. Fix workflow.run Activity never stopped in streaming OffThread execution (#4155)

The workflow.run OpenTelemetry Activity in StreamingRunEventStream.RunLoopAsync
was scoped to the method lifetime via 'using'. Since the run loop only exits on
cancellation, the Activity was never stopped/exported until explicit disposal.

Fix: Remove 'using' and explicitly dispose the Activity when the workflow reaches
Idle status (all supersteps complete). A safety-net disposal in the finally block
handles cancellation and error paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add root-level workflow.session activity spanning run loop lifetime\n\nImplements two-level telemetry hierarchy per PR feedback from lokitoth:\n- workflow.session: spans the entire run loop / stream lifetime\n- workflow_invoke: per input-to-halt cycle, nested within the session\n\nThis ensures the session activity stays open across multiple turns,\nwhile individual run activities are created and disposed per cycle.\n\nAlso fixes linkedSource CancellationTokenSource disposal leak in\nStreamingRunEventStream (added using declaration)."

* Address Copilot review: fix Activity/CTS disposal, rename activity, add error tag\n\n1. LockstepRunEventStream: Remove 'using' from Activity in async iterator\n   and manually dispose in finally block (fixes #4155 pattern). Also dispose\n   linkedSource CTS in finally to prevent leak.\n2. Tags.cs: Add ErrorMessage (\"error.message\") tag for runtime errors,\n   distinct from BuildErrorMessage (\"build.error.message\").\n3. ActivityNames: Rename WorkflowRun from \"workflow_invoke\" to \"workflow.run\"\n   for cross-language consistency.\n4. WorkflowTelemetryContext: Fix XML doc to say \"outer/parent span\" instead\n   of \"root-level span\".\n5. ObservabilityTests: Assert WorkflowSession absence when DisableWorkflowRun\n   is true.\n6. WorkflowRunActivityStopTests: Fix streaming test race by disposing\n   StreamingRun before asserting activities are stopped.\n7. StreamingRunEventStream/LockstepRunEventStream: Use Tags.ErrorMessage\n   instead of Tags.BuildErrorMessage for runtime error events."

* Review fixes: revert workflow_invoke rename, use 'using' for linkedSource, move SessionStarted earlier\n\n- Revert ActivityNames.WorkflowRun back to \"workflow_invoke\" (OTEL semantic convention contract)\n- Use 'using' declaration for linkedSource CTS in LockstepRunEventStream (no timing sensitivity)\n- Move SessionStarted event before WaitForInputAsync in StreamingRunEventStream to match Lockstep behavior"

* Improve naming and comments in WorkflowRunActivityStopTests"

* Prevent session Activity.Current leak in lockstep mode, add nesting test

Save and restore Activity.Current in LockstepRunEventStream.Start() so the
session activity doesn't leak into caller code via AsyncLocal. Re-establish
Activity.Current = sessionActivity before creating the run activity in
TakeEventStreamAsync to preserve parent-child nesting.

Add test verifying app activities after RunAsync are not parented under the
session, and that the workflow_invoke activity nests under the session."

* Fix stale XML doc: WorkflowRun -> WorkflowInvoke in ObservabilityTests

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 19:42:45 +00:00

322 lines
13 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.Execution;
/// <summary>
/// A modern implementation of IRunEventStream that streams events as they are created,
/// using System.Threading.Channels for thread-safe coordination.
/// </summary>
internal sealed class StreamingRunEventStream : IRunEventStream
{
private readonly Channel<WorkflowEvent> _eventChannel;
private readonly ISuperStepRunner _stepRunner;
private readonly InputWaiter _inputWaiter;
private readonly CancellationTokenSource _runLoopCancellation;
private readonly bool _disableRunLoop;
private Task? _runLoopTask;
private RunStatus _runStatus = RunStatus.NotStarted;
private int _completionEpoch; // Tracks which completion signal belongs to which consumer iteration
public StreamingRunEventStream(ISuperStepRunner stepRunner, bool disableRunLoop = false)
{
this._stepRunner = stepRunner;
this._runLoopCancellation = new CancellationTokenSource();
this._inputWaiter = new();
this._disableRunLoop = disableRunLoop;
// Unbounded channel - events never block the producer
// This allows events to flow freely during superstep execution
this._eventChannel = Channel.CreateUnbounded<WorkflowEvent>(new UnboundedChannelOptions
{
SingleReader = true, // Only one consumer at a time (enforced by AsyncRunHandle)
SingleWriter = false, // Events can come from multiple threads during superstep execution
AllowSynchronousContinuations = false // Prevent potential deadlocks
});
}
public void Start()
{
// Start the background run loop that drives superstep execution
if (!this._disableRunLoop)
{
this._runLoopTask = Task.Run(() => this.RunLoopAsync(this._runLoopCancellation.Token));
}
}
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
using CancellationTokenSource errorSource = new();
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
// Subscribe to events - they will flow directly to the channel as they're raised
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
// Start the session-level activity that spans the entire run loop lifetime.
// Individual run-stage activities are nested within this session activity.
Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
Activity? runActivity = null;
sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
try
{
// Wait for the first input before starting
// The consumer will call EnqueueMessageAsync which signals the run loop
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
while (!linkedSource.Token.IsCancellationRequested)
{
// Start a new run-stage activity for this input→processing→halt cycle
runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
// Run all available supersteps continuously
// Events are streamed out in real-time as they happen via the event handler
while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested)
{
await this._stepRunner.RunSuperStepAsync(linkedSource.Token).ConfigureAwait(false);
}
// Update status based on what's waiting
this._runStatus = this._stepRunner.HasUnservicedRequests
? RunStatus.PendingRequests
: RunStatus.Idle;
// Signal completion to consumer so they can check status and decide whether to continue
// Increment epoch so next consumer iteration gets a new completion signal
// Capture the status at this moment to avoid race conditions with event reading
int currentEpoch = Interlocked.Increment(ref this._completionEpoch);
RunStatus capturedStatus = this._runStatus;
await this._eventChannel.Writer.WriteAsync(new InternalHaltSignal(currentEpoch, capturedStatus), linkedSource.Token).ConfigureAwait(false);
// Close the run-stage activity when processing halts.
// A new run activity will be created when the next input arrives.
if (runActivity is not null)
{
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity.Dispose();
runActivity = null;
}
// Wait for next input from the consumer
// Works for both Idle (no work) and PendingRequests (waiting for responses)
await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false);
// When signaled, resume running
this._runStatus = RunStatus.Running;
}
}
catch (OperationCanceledException)
{
// Expected during shutdown
}
catch (Exception ex)
{
// Record error on the run-stage activity if one is active
if (runActivity is not null)
{
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.ErrorMessage, ex.Message },
}));
runActivity.CaptureException(ex);
}
// Record error on the session activity
if (sessionActivity is not null)
{
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.ErrorMessage, ex.Message },
}));
sessionActivity.CaptureException(ex);
}
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
}
finally
{
this._stepRunner.OutgoingEvents.EventRaised -= OnEventRaisedAsync;
this._eventChannel.Writer.Complete();
// Mark as ended when run loop exits
this._runStatus = RunStatus.Ended;
// Stop the run-stage activity if not already stopped (e.g. on cancellation or error)
if (runActivity is not null)
{
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity.Dispose();
}
// Stop the session activity — the session always ends when the run loop exits
if (sessionActivity is not null)
{
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
sessionActivity.Dispose();
}
}
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
{
// Write event directly to channel - it's thread-safe and non-blocking
// The channel handles all synchronization internally using lock-free algorithms
// Events flow immediately to consumers rather than being batched
await this._eventChannel.Writer.WriteAsync(e, linkedSource.Token).ConfigureAwait(false);
if (e is WorkflowErrorEvent error)
{
errorSource.Cancel();
}
}
}
/// <summary>
/// Signals that new input has been provided and the run loop should continue processing.
/// Called by AsyncRunHandle when the user enqueues a message or response.
/// </summary>
public void SignalInput() => this._inputWaiter.SignalInput();
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(
bool blockOnPendingRequest,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Get the current epoch - we'll only respond to completion signals from this epoch or later
int myEpoch = Volatile.Read(ref this._completionEpoch) + 1;
// Use custom async enumerable to avoid exceptions on cancellation.
NonThrowingChannelReaderAsyncEnumerable<WorkflowEvent> eventStream = new(this._eventChannel.Reader);
await foreach (WorkflowEvent evt in eventStream.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Filter out internal signals used for run loop coordination
if (evt is InternalHaltSignal completionSignal)
{
// Ignore completion signals from previous iterations
if (completionSignal.Epoch < myEpoch)
{
continue;
}
// Check for cancellation at superstep boundaries (before processing completion signal)
// This allows consumers to stop reading events cleanly between supersteps
if (cancellationToken.IsCancellationRequested)
{
yield break;
}
// Check if we should stop streaming based on the status captured at completion time
// This avoids race conditions where _runStatus changes while events are being read
// - Idle: Workflow completed, no pending requests
// - Ended: Run loop disposed/cancelled
// Note: PendingRequests is handled by WatchStreamAsync's do-while loop
if (completionSignal.Status is RunStatus.Idle or RunStatus.Ended)
{
yield break;
}
if (!blockOnPendingRequest && completionSignal.Status is RunStatus.PendingRequests)
{
yield break;
}
// Otherwise continue reading (more events coming after input provided)
continue;
}
// RequestHaltEvent signals the end of the event stream
if (evt is RequestHaltEvent)
{
yield break;
}
if (cancellationToken.IsCancellationRequested)
{
yield break;
}
yield return evt;
}
}
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
// Thread-safe read of status (enum is read atomically on most platforms)
return new ValueTask<RunStatus>(this._runStatus);
}
/// <summary>
/// Clears all buffered events from the channel.
/// This should be called when restoring a checkpoint to discard stale events from superseded supersteps.
/// </summary>
public void ClearBufferedEvents()
{
// Drain all events currently in the channel buffer
// We discard all events since they're from a timeline that's been superseded by the checkpoint restore
while (this._eventChannel.Reader.TryRead(out _))
{
// Discard each event (including InternalCompletionSignals)
}
// After clearing, signal the run loop to continue if needed
// The run loop will send a new completion signal when it finishes processing from the restored state
this.SignalInput();
}
public async ValueTask StopAsync()
{
// Cancel the run loop
this._runLoopCancellation.Cancel();
// Release the event waiter, if any
this._inputWaiter.SignalInput();
// Wait for clean shutdown
if (this._runLoopTask != null)
{
try
{
await this._runLoopTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected during cancellation
}
}
}
public async ValueTask DisposeAsync()
{
await this.StopAsync().ConfigureAwait(false);
// Dispose resources
this._runLoopCancellation.Dispose();
this._inputWaiter.Dispose();
}
/// <summary>
/// Internal signal used to mark completion of a work batch and allow status checking.
/// This is never exposed to consumers.
/// </summary>
private sealed class InternalHaltSignal(int epoch, RunStatus status) : WorkflowEvent
{
public int Epoch => epoch;
public RunStatus Status => status;
}
}