e-naxosMAF COURSE / COURS
Français

MICROSOFT AGENT FRAMEWORK · 0.16.0-rc.1

Waiting, checkpoints and idempotent effects

Preserve a pending request and replay a decision without adding a second SQLite effect.

Objectives

Wait beyond the console

A regional-pilot proposal awaits a business decision. Closing the console must preserve the request; a repeated response must not duplicate the created row. Video3 supplied the suspension/recovery mental model. The current scenario binds an immutable BacklogDraft to an operation and inspects real SQLite storage. Conversational session, engine checkpoint and business state have distinct responsibilities.

Three recovery levels
MechanismScope
Demo300Real RequestPort; CLI decision supplied immediately in the live process.
Demo310CreateInMemory; rebuilt graph with the manager retained in the same process.
Demo320JSON file checkpoints, waiting.json and business.db; start then resume in separate processes.

Capture the correct boundary

PrepareApprovalV1 validates and registers the proposal. BusinessApprovalV1 is a RequestPort<ApprovalTicket, ApprovalDecision>. ApplyApprovalV1 applies the decision. The engine emits RequestInfoEvent for the wait; the CLI supplies the response through CreateResponse then SendResponseAsync. The external party is simulated by the CLI choice; the request, correlation and engine are real.

var prepare = new Prepare(store);
var port = RequestPort.Create<ApprovalTicket, ApprovalDecision>("BusinessApprovalV1");
var apply = new Apply(store, failAfterWrite);
return new WorkflowBuilder(prepare).AddEdge(prepare, port).AddEdge(port, apply)
    .WithOutputFrom(apply).Build();

shared/Maf.Durable/ApprovalWorkflow.cs · L20–L24 · 9a895c94534c

Source code · Maf.Durable · Folder

A superstep is an engine execution and synchronization cycle over pending messages and recipients. It is not an arbitrarily chosen business group. CaptureWaitingAsync first observes RequestInfoEvent, then retains the checkpoint from the following SuperStepCompletedEvent. An earlier checkpoint may lack the request. waiting.json is published through a temporary file and rename only after this capture.

string? requestId = null;
await foreach (var evt in run.WatchStreamAsync(cancellationToken))
{
    Console.WriteLine("Event: " + evt.GetType().Name);
    if (evt is WorkflowErrorEvent error) throw new InvalidOperationException("Workflow failed.", error.Exception);
    if (evt is RequestInfoEvent request)
    {
        var ticket = request.Request.Data.As<ApprovalTicket>();
        if (ticket?.OperationId != operationId) throw new InvalidOperationException("Unexpected pending operation.");
        requestId = request.Request.RequestId;
    }
    if (requestId is not null && evt is SuperStepCompletedEvent { CompletionInfo.Checkpoint: { } checkpoint })
        return new(ApprovalWorkflow.Version, checkpoint.SessionId, checkpoint.CheckpointId, operationId, requestId);
}
throw new InvalidOperationException("No checkpoint containing the pending request was observed.");

shared/Maf.Durable/ApprovalDemo.cs · L78–L92 · 9a895c94534c

Source code · Maf.Durable · Folder

Reconstruct and correlate the request

waiting.json retains GraphVersion, SessionId, CheckpointId, OperationId and RequestId. The session/checkpoint pair locates native state, OperationId identifies the business operation and RequestId identifies the engine request. On resume, the host rebuilds the graph with the same fixed identities and contracts, then calls ResumeStreamingAsync. The pending request reappears with the same RequestId; the host checks that identity and OperationId before responding. It never reconstructs a request from a console string.

if (ticket?.OperationId != operationId || decision.OperationId != operationId
    || (requestId is not null && request.Request.RequestId != requestId))
    throw new ArgumentException("The response does not match the pending request.");
store.RecordDecision(decision);
Console.WriteLine("Responding: " + request.Request.RequestId);
await run.SendResponseAsync(request.Request.CreateResponse(decision));

shared/Maf.Durable/ApprovalDemo.cs · L107–L112 · 9a895c94534c

Source code · Maf.Durable · Folder

Resume from the waiting boundary
  1. start → SQLite proposal → RequestInfoEvent → checkpoint → waiting.json → process exit
  2. resume → same graph + checkpoint → same re-emitted request
  3. Check identities → record SQLite decision → SendResponseAsync
  4. ApplyApprovalV1 → SQLite transaction → result; PrepareApprovalV1 must not run again

Bind the decision to an immutable proposal

OperationId is generated once at start and bound to proposal JSON in Approvals. Reusing the key for another draft fails. The first valid decision is persisted before sending it to the engine. Only approve and reject with a nonblank comment of one to 500 characters are accepted. Replay must repeat the exact choice and comment; a conflicting or edited decision fails. Refusal remains final and creates no ApprovedBacklogItems row.

In-process approval and durable decision
MechanismScope
Tool approval Demo110/120Authorizes an invocation under the active host contract; it is not Demo320’s recovery protocol.
Business decision Demo320Proposal bound to OperationId, persisted decision, correlated request and idempotent local effect.

The CLI is a trusted decision source in this example. No login, reviewer role or independent human business validation is established. This protocol does not use MAF’s native function-tool approval.

Commit, interruption and replay

Apply records the decision again to verify its identity, then inserts ApprovedBacklogItems and marks Approvals.Completed in one SQLite transaction. The UNIQUE constraint on OperationId protects the business row even under concurrent store calls. Replay returns the same ItemId. status joins the row and prints StoredItem: an independent read of the engine result. A success event alone is insufficient.

public ApprovalState Apply(ApprovalDecision decision)
{
    RecordDecision(decision);
    using var connection = Open();
    using var transaction = connection.BeginTransaction();
    using var command = connection.CreateCommand();
    command.Transaction = transaction;
    command.CommandText = """
        INSERT INTO ApprovedBacklogItems (OperationId,Draft,CreatedUtc)
        SELECT OperationId,Draft,$utc FROM Approvals WHERE OperationId=$id AND Status IN ('Approved','Completed')
        ON CONFLICT(OperationId) DO NOTHING;
        UPDATE Approvals SET Status='Completed' WHERE OperationId=$id AND Status='Approved';
        """;
    command.Parameters.AddWithValue("$id", decision.OperationId);
    command.Parameters.AddWithValue("$utc", DateTimeOffset.UtcNow.ToString("O"));
    command.ExecuteNonQuery();
    transaction.Commit();
    return Read(decision.OperationId);

shared/Maf.Durable/ApprovalStore.cs · L80–L97 · 9a895c94534c

Source code · Maf.Durable · Folder

Interruption windows
MechanismScope
After published waitWaiting, zero effects; resume with a valid decision.
After decision, before effectApproved or Rejected; resend the same decision.
During business transactionInsert and Completed roll back together on failure.
After commit, before engine completionCompleted and one row; replay the waiting checkpoint, same ItemId.

Inject failure after commit

The Demo320 page and exercise detail start, status, resume and replay. On a new operation, append approve "External business decision." --fail-after-write to resume. The program returns 1 after commit; status must nevertheless read Completed and a StoredItem. Repeat with the same comment and no flag: completion must return the same ItemId and one row. The waiting receipt is deliberately retained after completion to test this case.

dotnet test tests/Maf.Durable.Tests/Maf.Durable.Tests.csproj -m:1 -c Release --no-build --no-restore

A precisely scoped local guarantee

Keep checkpoints, waiting.json and business.db together. A file lease excludes multiple CLI hosts on one directory; concurrent store tests establish no distributed scheduler. These files and SQLite do not share one transaction. Interruption during initial publication, corruption, file loss, power failure and remote filesystems are unqualified. If start fails without a receipt, preserve the directory for inspection and use a fresh directory.

Uniqueness covers the local SQLite effect. It does not promise a single execution of every executor. A remote payment, email or ticket would require its own durable idempotency key or an outbox/receiver contract. Changing graph version, identities or contracts requires migration; unknown versions are rejected. Authentication, expiration, amendments, encryption and retention remain outside this example.

Run the three levels

Prerequisites

Continue reading

Previous

Next

lot009-workflows-1 · 2026-10-24 · 9a895c94534cd5fc6f3da883e37e5f7856e588c1