microsoft / microsoft/agent-framework

.NET: [Bug]: Workflow handler cancellation is reported as executor failure

Open
#8,092 3 comments 0 reactions 1 assignee View on GitHub

@peibekwe is already working on this.

Since Sep 7, 2026.

.NET reproduced workflows
Dominant language
Python
Stars
13.6k
Forks
2.3k
Avg merge
2d 45m
Merged PRs (30d)
358

Description

## Description

When a cancellation-aware .NET workflow delegate handler observes cancellation through the runtime-provided `CancellationToken`, a direct `Executor.ExecuteCoreAsync` call converts the cancellation into an executor failure.

This is reproducible on current `main` (`2c49f50cf`) with .NET SDK `10.0.303` on macOS arm64:

```text
Expected: System.OperationCanceledException
Actual: System.Reflection.TargetInvocationException
Inner: System.Threading.Tasks.TaskCanceledException
```

At the `ExecuteCoreAsync` layer, the same execution also submits one `ExecutorFailedEvent` whose data is the `TaskCanceledException`. Whether that event is subsequently observable through a complete lockstep or off-thread public event stream is mode- and timing-dependent because those streams stop yielding after cancellation.

There are two inconsistent cancellation paths:

1. Delegate handlers registered through `RouteBuilder` let `OperationCanceledException` reach `MessageRouter.RouteMessageAsync`, whose catch-all converts it to `CallResult.RaisedException(...)`.
2. The legacy reflection handler path already converts `OperationCanceledException` to `CallResult.Cancelled(...)`, but `Executor.ExecuteCoreAsync` treats every non-success result, including `IsCancelled`, as an `ExecutorFailedEvent` followed by `TargetInvocationException`.

PR #1280 introduced propagation of the runtime token together with the explicit `CallResult.IsCancelled` / `CallResult.Cancelled` state. The reflection adapter produces that state for `OperationCanceledException`, but the common executor path does not branch on `IsCancelled`; it routes every non-success result through the existing failure path. Both lockstep and off-thread run-event implementations have normal `OperationCanceledException` handling, which the wrapper cannot reach. This strongly suggests incomplete cancellation-state integration, although #1280 does not document the intended outer exception/event contract.

Proposed behavior, subject to maintainer confirmation: cooperative cancellation associated with the runtime-provided token should remain cancellation rather than being wrapped in `TargetInvocationException` or classified as an executor failure. Existing `TargetInvocationException` behavior for non-cancellation handler exceptions should remain unchanged.

The behavior for an `OperationCanceledException` raised while the runtime token is not cancelled needs maintainer confirmation. The two routing implementations currently disagree, so this report does not assume that boundary.

## Code Sample

```csharp
[Fact]
public async Task HandlerCancellationPropagatesWithoutFailureEventAsync()
{
using CancellationTokenSource cancellationSource = new();
FunctionExecutor executor = new(
"cancel",
async (message, context, cancellationToken) =>
{
cancellationSource.Cancel();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
});
TestWorkflowContext context = new(executor.Id);

Task ActAsync() => executor.ExecuteCoreAsync(
"message",
new(typeof(string)),
context,
cancellationSource.Token).AsTask();

await Assert.ThrowsAnyAsync(ActAsync);
Assert.DoesNotContain(
context.EmittedEvents,
evt => evt is ExecutorFailedEvent);
}
```

The test fails on current `main` before reaching the event assertion:

```text
Assert.ThrowsAny() Failure: Exception type was not compatible
Expected: typeof(System.OperationCanceledException)
Actual: typeof(System.Reflection.TargetInvocationException)
---- System.Reflection.TargetInvocationException
-------- System.Threading.Tasks.TaskCanceledException
```

A separate current-behavior test confirmed that `ExecuteCoreAsync` submits an `ExecutorFailedEvent` containing the same `TaskCanceledException`. Public event-stream observability still needs independent lockstep and off-thread coverage.

## Error Messages / Stack Traces

```text
System.Reflection.TargetInvocationException:
Error invoking handler for

Inner exception:
System.Threading.Tasks.TaskCanceledException:
A task was canceled.

Relevant frames:
MessageRouter.RouteMessageAsync(...)
Executor.ExecuteCoreAsync(...)
```

## Package Versions

`Microsoft.Agents.AI.Workflows` from `main` at `2c49f50cf08ebb6c1687146336f039051f159333`.

## .NET Version

`.NET SDK 10.0.303`, macOS arm64.

## Additional Context

A bounded implementation should:

- preserve cooperative cancellation through the common executor path;
- avoid `ExecutorFailedEvent` and `TargetInvocationException` for that path;
- align delegate and reflection-based handler behavior;
- add regression coverage for direct execution plus lockstep and off-thread environments;
- explicitly pin the maintainer-approved behavior for unrelated/foreign cancellation exceptions.

If maintainers confirm the cancellation contract, I am willing to implement the focused fix and regression coverage within three days.

AI assistance was used for call-path analysis and drafting. The reported failing and current-behavior tests were executed in an isolated checkout; no passing result is claimed.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.