Skip to content

.NET: [Bug]: HITL in Durable workflows the Executor TInput state is lost after approval is granted. #76

Closed

Description

@Sachin-Nand

Description

When a durable workflow pauses for Human-in-the-Loop (HITL) approval and execution resumes after approval is granted the executor does not retain its original TInput state. As a result, the input passed to the executor before the approval is lost or reset, preventing the executor from continuing with the same context.

The executor should resume with the original TInput instance so that workflow execution can continue seamlessly without requiring the input to be reconstructed again.

In the code sample,"isapproved" property value is maintained post HITL approval that comes from the value passed during the workflow invocation. Other property values like "inputnumber" and "id" are reset to empty string.

Code Sample

public sealed class _Response
{
    [JsonPropertyName("id")]
    public string Id { get; set; } = string.Empty;

    [JsonPropertyName("inputnumber")]
    public string InputNumber { get; set; } = string.Empty;

    [JsonPropertyName("isapproved")]
    public Boolean IsApproved { get; set; }
}


internal sealed class SendNotificationExecutor() : Executor<_Response>("SendNotificationExecutor")
{
    [YieldsOutput(typeof(string))]
    public override async ValueTask HandleAsync(_Response response, IWorkflowContext context, CancellationToken cancellationToken = default)
    {
        var numbertype = await context.ReadStateAsync<DetectionResult>(response.Id, scopeName: NumbervalueConstants.NumberTypeScope);
        await context.YieldOutputAsync($"The value {response.InputNumber} is  forwarded to related department.");
    }
}

PowerShell Invocation

$json = '{
  "eventName": "NotificationApproval",
  "response": { "isapproved": true}
}'

Invoke-RestMethod `
-Uri "http://localhost:7001/api/workflows/NumberDetector/respond/{runId}" `
-Method Post `
-ContentType "text/json" `
-Body $json

Error Messages / Stack Traces

Package Versions

Microsoft.Agents.AI.Hosting.AzureFunctions : 1.8.0-preview.260528.1 , Microsoft.Agents.AI.Workflows : 1.8.0 , Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged : 1.8.1 , Microsoft.Azure.Functions.Worker.Extensions.DurableTask : 1.16.5

.NET Version

.NET 10.0

Additional Context

No response

Activity

  1. added theissue type on Jun 28, 2026
  2. github-actions commented on Jun 28, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    Repro: DurableExecutorDispatcher.ExecuteRequestPortAsync in dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs (line 129) returns only the external event response, discarding the original input parameter that was passed to the RequestPort. The original input IS stored in PendingEvents (line 117) but is never merged with the response before routing downstream. Minimal repro: create a workflow with Executor -> RequestPort -> Executor where the downstream executor expects properties from the original input; send a partial response via the respond API — the downstream executor's TInput will have default values for all properties not in the response.

    • Failing test: dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/RequestPortInputStateLossTests.cs
    • Files examined: dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs, dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs, dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs, dotnet/src/Microsoft.Agents.AI.Workflows/RequestPort.cs, dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs, dotnet/samples/04-hosting/DurableWorkflows/ConsoleAps/08_WorkflowHITL/Executors.cs, dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs
    • Tests run: dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/RequestPortInputStateLossTests.cs
    • Reported version: 1.8.0
  3. kshyju commented on Jun 29, 2026

    @kshyju
    Contributor

    The behavior you are seeing is by design and matches MAF core (in-process) behavior. It is not a durable workflow specific bug.

    A RequestPort<TRequest, TResponse> forwards only the response to downstream executors. The original request sent into the port is never carried forward, in either the durable or the in-process engine. So when the approver replies with a partial body like {"Approved": true}, the downstream executor only gets a payload with those subset of properties.

    Two supported workarounds:

    1. Shared state . Before the port, persist the original request to shared workflow state with QueueStateUpdateAsync(key, request, scopeName). After the approval, read it back with ReadStateAsync(...) and merge it with the approver's outcome. In the durable engine, state under a named scope is shared across executors in the run, so the original fields survive even a partial response.
    2. Echo the fields in the response body. Include the original fields in the /respond payload so they flow through as part of the response.

    Hope this information helps.

  4. Sachin-Nand commented on Jun 29, 2026

    @Sachin-Nand
    Author

    Thank you for looking into this.

    I have used the first option i.e. the shared state to persist the request. But as RequestPort resets all the properties, there's no way to access the values stored from the shared state afterward.

    If you look at code that I posted earlier , I am trying to read from the shared state

    internal sealed class SendNotificationExecutor() : Executor<_Response>("SendNotificationExecutor")
    {
        [YieldsOutput(typeof(string))]
        public override async ValueTask HandleAsync(_Response response, IWorkflowContext context, CancellationToken cancellationToken = default)
        {
            var numbertype = await context.ReadStateAsync<DetectionResult>(response.Id, scopeName: NumbervalueConstants.NumberTypeScope);
          await context.YieldOutputAsync($"The value {response.InputNumber} is  forwarded to related department.");
        }
    }
    
    
    `var numbertype = await context.ReadStateAsync<DetectionResult>(response.Id, scopeName: NumbervalueConstants.NumberTypeScope);`
    

    where Id is the key .

    However as the Id property in the response object loses its initial value after the Human-in-the-Loop (HITL) step it is not possible to read from the shared state which subsequently causes the executor to error out.

  5. Sachin-Nand commented on Jun 29, 2026

    @Sachin-Nand
    Author

    I did a quick check for InProcess workflow execution and I don't see this issue there.

    The RequestPort does maintain the TInput state of the Executor in the InProcess workflow execution.

  6. kshyju commented on Jun 30, 2026

    @kshyju
    Contributor

    Thanks, that is a useful data point. Before I go further, I want to make sure I am comparing the same thing.

    In both engines only the response is routed downstream; neither merges the original request. What changes the outcome is how the response is built. In a quick in-process repro I see both results:

    Partial object {isapproved:true}     : Id='' InputNumber='' IsApproved=True   (fields lost)
    Reusing the original request object  : Id='ABC-123' InputNumber='42' IsApproved=True
    

    So in-process also drops the fields if the response is a fresh partial object, and keeps them if it is built from the original request.

    How are you sending the response in your in-process test? If you can share that snippet (the part that constructs and sends the response), I can confirm whether you are hitting the same path as the durable /respond endpoint, which always deserializes a fresh object from the posted JSON.

    On the shared-state error: it is likely an invalid key. You read with response.Id, but after a partial response Id is empty, so the lookup uses an empty key and misses. If you confirm that is what you are seeing, the fix is to store and read under a stable key (a constant per run, or the run id) rather than a field that only exists on the original request. I will follow up with the full shared-state pattern and a possible hosting-side enhancement once we are aligned on the in-process question.

  7. Sachin-Nand commented on Jun 30, 2026

    @Sachin-Nand
    Author

    Thank you, Shyju, for looking into this once again.

    I think it would be easier if I share a screencast with you to understand the issue I'm facing. Hopefully that will help us get on the same page. Please give me some time for this

    Regarding the shared state, I'm storing the key-value pair in ExecutorA before the RequestPort is invoked:

    await context.QueueStateUpdateAsync(Input.Id, Input, scopeName: NumbervalueConstants.NumbervalueScope);

    In the same executor, I also make sure that the Id property in response object is set

    response.Id = input.Id;

    Then in ExecutorB, which executes immediately after the RequestPort, I try to read the shared state using the response.Id value that was set in ExecutorA:

    var type = await context.ReadStateAsync<DetectionResult>(response.Id, scopeName: NumbervalueConstants.NumberTypeScope);

    However, after the RequestPort completes, response.Id is reset to empty instead of retaining the value assigned in ExecutorA. A result of which I'm unable to retrieve the state using the same key.

  8. Sachin-Nand commented on Jun 30, 2026

    @Sachin-Nand
    Author

    Please find the screencast . At first I executed the non durable workflow and then executed a durable workflow with DTS running on Docker

    https://1drv.ms/v/c/2b868dfe462cb2eb/IQBjuI0txlwaRp9Ckp2eJzb6Aafi8XdeqtcVbeJqkvxwyws?e=liuBZB

  9. kshyju commented on Jun 30, 2026

    @kshyju
    Contributor

    Sachin-Nand Would you mind sharing a minimal repro? A GH repository with your repro app will be great.

  10. Sachin-Nand commented on Jul 1, 2026

    @Sachin-Nand
    Author

    I have created the repo on GH

    Durable Workflows >> https://github.com/Sachin-Nand/Azure_Durable_Workflow_HITL

    Non Durable Workflows >> https://github.com/Sachin-Nand/Non_Durable_Workflow_HITL

    Please ensure you have the following values entered in appsettings.json

    "AppSettings": {
    "Chat_DeploymentName": "",
    "EndPoint": "",
    "ApiKey": ""
    }

  11. kshyju commented on Jul 2, 2026

    @kshyju
    Contributor

    Thanks for sharing the repro repos. They helped pinpoint exactly what's happening.

    Why fields survive in your in-process app

    In your non-durable Program.cs, HandleExternalRequest does:

    request.TryGetDataAs<_Response>(out var response);
    response.approved = true;
    request.CreateResponse(response);
    

    TryGetDataAs returns the original in-memory object with all fields (Id, InputNumber) already populated. You set approved on that same object and send it back. All fields survive because you're reusing the original object.

    Why fields are lost in your durable app

    Your Azure Functions app sends the response via HTTP:

    {"isapproved": true}

    This deserializes into a fresh _Response where Id and InputNumber are empty strings.

    This is not a durable vs in-process difference. If you build a durable console app that responds the same way your in-process code does (extract original via GetInputAs, modify, send back), all fields will survive. The difference is purely in how the response is constructed: reusing the original object vs sending partial JSON over HTTP.

    Why your shared-state workaround also failed

    In your durable app, you store state keyed by input.Id upstream, but read it with response.Id downstream. After a partial HTTP response, response.Id is an empty string, so the lookup misses. Using a stable key (e.g., a constant or the workflow run ID) fixes this.

    Workarounds

    1. Send the full object in the HTTP response body (include id, inputnumber, and isapproved)
    2. Use shared state with a stable key (not a field from the response object)
  12. Sachin-Nand commented on Jul 6, 2026

    @Sachin-Nand
    Author

    Thank you kshyju for having a look into this. Please give me a day or two to look into your findings.

  13. Sachin-Nand commented on Jul 14, 2026

    @Sachin-Nand
    Author

    Hello kshyju ,

    Sorry for the delayed response .

    I tried both of your solutions and they both work as expected, thanks for that.

    But I still think it will be more prudent if there is some mechanism through which the state is maintained across complete workflow lifecycle because sometimes its not possible to reconstruct and resend the complete object for HITL approval event as in some cases there are around 15+ properties that will have to be resend thru HTTP response.

    IMHO , your second option of using a stable key is much more feasible as there would be no need to send over the entire object over HTTP response but there are some challenges with this approach.

    The stable key approach would mean that I will have to send a static key as an argument to the HandleAsync method of the executor

    For instance

     public override async ValueTask HandleAsync(_Response response, IWorkflowContext context, int key, CancellationToken cancellationToken = default)
     {
     // some operation to read the value of the key from the shared state
     }

    I am not sure how can I pass the value to the key in the workflow.

  14. safal207 commented on Aug 23, 2026

    @safal207

    This is a good example of why durable workflow support should be decomposed into independent guarantees rather than treated as one binary capability.
    Here the workflow can pause and resume, and part of the approval state survives, but the original executor input does not survive intact.
    So:
    durable checkpoint ≠ complete state preservation ≠ semantically correct resume
    We recently wrote up this distinction across several agent runtimes:
    https://safal207.github.io/RESONANCE/conformance-is-a-vector-not-a-verdict.ru.html⁠�
    For this bug, I think the useful invariant is:
    executor receives TInput X
    →
    workflow suspends for HITL
    →
    approval is persisted
    →
    workflow resumes
    →
    executor must observe the same semantic TInput X
    The approval response should update only the part of state it owns. It should not implicitly replace the pre-interruption executor input with a partial object.
    A regression fixture could therefore record a digest of the original executor input before suspension and compare it after resume:
    input_before = hash(canonical(TInput))
    input_after = hash(canonical(resumed TInput excluding fields intentionally mutated by approval))

    input_before == input_after
    If the workflow can resume while silently resetting fields such as id or inputnumber, then the persistence layer has succeeded structurally but the resume contract has failed semantically.
    That distinction is worth making explicit because workflow resumed successfully is otherwise a much stronger claim than the evidence supports.

  15. kshyju commented on Aug 25, 2026

    @kshyju
    Contributor

    Sachin-Nand

    Sorry for the delayed response. Glad the workarounds are working. Agreed that shared state is the better fit when the object has many properties, since it avoids having to echo everything back in the HTTP response.

    The stable key doesn't need to be passed as a parameter to HandleAsync. You can use a simple constant that both executors share. For example:

    // Shared constant - both executors reference this
    public static class StateKeys
    {
        public const string OriginalRequest = "OriginalRequest";
    }
    
    // Upstream executor: store before the RequestPort
    public override async ValueTask<_Response> HandleAsync(
        string message, IWorkflowContext context, CancellationToken ct = default)
    {
        var response = new _Response { Id = Guid.NewGuid().ToString(), InputNumber = message };
        await context.QueueStateUpdateAsync(
            StateKeys.OriginalRequest, response, scopeName: "MyWorkflow", ct);
        return response;
    }
    
    // Downstream executor: read it back after approval
    public override async ValueTask HandleAsync(
        _Response response, IWorkflowContext context, CancellationToken ct = default)
    {
        var original = await context.ReadStateAsync<_Response>(
            StateKeys.OriginalRequest, scopeName: "MyWorkflow", ct);
        // original has all the fields; response has the approval decision
    }

    Both executors use the same constant key ("OriginalRequest") and the same scope name ("MyWorkflow"). No changes to the HandleAsync signature needed.

  16. kshyju commented on Aug 25, 2026

    @kshyju
    Contributor

    Aleksey Safonov (@safal207) Thanks for the input. The current behavior is that the response endpoint delivers exactly what the caller sends. Auto-merging partial responses onto the original request is an interesting enhancement idea, and we'd welcome a feature request for that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions