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
- Superstep and checkpoint boundary
- External request and graph identity
- Decision bound to an immutable draft
- Commit, crash and replay without distributed exactly-once
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.
| Mechanism | Scope |
|---|---|
| Demo300 | Real RequestPort; CLI decision supplied immediately in the live process. |
| Demo310 | CreateInMemory; rebuilt graph with the manager retained in the same process. |
| Demo320 | JSON 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();
Source code · Maf.Durable · Folder
- shared/Maf.Durable/Maf.Durable.csproj
- demos/Demo300_ExternalRequest/Demo300.App.csproj
- demos/Demo310_CheckpointResume/Demo310.App.csproj
- demos/Demo320_DurableApproval/Demo320.App.csproj
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.");
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));
Source code · Maf.Durable · Folder
- shared/Maf.Durable/Maf.Durable.csproj
- demos/Demo300_ExternalRequest/Demo300.App.csproj
- demos/Demo310_CheckpointResume/Demo310.App.csproj
- demos/Demo320_DurableApproval/Demo320.App.csproj
- start → SQLite proposal → RequestInfoEvent → checkpoint → waiting.json → process exit
- resume → same graph + checkpoint → same re-emitted request
- Check identities → record SQLite decision → SendResponseAsync
- 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.
| Mechanism | Scope |
|---|---|
| Tool approval Demo110/120 | Authorizes an invocation under the active host contract; it is not Demo320’s recovery protocol. |
| Business decision Demo320 | Proposal 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);
Source code · Maf.Durable · Folder
- shared/Maf.Durable/Maf.Durable.csproj
- demos/Demo300_ExternalRequest/Demo300.App.csproj
- demos/Demo310_CheckpointResume/Demo310.App.csproj
- demos/Demo320_DurableApproval/Demo320.App.csproj
| Mechanism | Scope |
|---|---|
| After published wait | Waiting, zero effects; resume with a valid decision. |
| After decision, before effect | Approved or Rejected; resend the same decision. |
| During business transaction | Insert and Completed roll back together on failure. |
| After commit, before engine completion | Completed 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-restoreA 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
