Durable AI Agents in .NET: Replay, Checkpointing, Human-in-the-Loop and Exactly-Once Illusions
Un agente AI che risponde in tre secondi può vivere dentro una normale richiesta HTTP. Un agente che interroga più sistemi, attende un'approvazione e infine esegue un'operazione amministrativa è invece un processo distribuito di lunga durata. La differenza non è quantitativa: cambiano le garanzie richieste, il modello di errore e il modo in cui viene rappresentato lo stato.
Il 12 ottobre 2026 Microsoft ha pubblicato un'analisi di cinque casi reali per Durable Task. Il messaggio più importante è che l'esecuzione durevole permette di ricordare e riprendere il coordinamento, ma non certifica l'esito delle operazioni esterne. In questo approfondimento costruiremo il modello architetturale e un laboratorio C# verificabile anche senza credenziali cloud, poi vedremo come portarlo su Durable Task Scheduler e Microsoft Agent Framework.
1. Il failure model: dove si perde davvero lo stato?
Consideriamo un agente di incident response. Raccoglie log e metriche, chiede a un LLM una proposta di remediation, attende l'approvazione di un operatore e chiama una API per applicarla. L'operazione può richiedere ore. Se il processo viene terminato durante l'attesa, una variabile C# e una continuation await non sono più disponibili.
La difficoltà non è riavviare il metodo: è distinguere le attività completate, quelle in corso e quelle il cui risultato è sconosciuto. Un timeout HTTP, per esempio, non dimostra che il server remoto non abbia eseguito l'operazione.
2. Durable Task: orchestrator, activities, history e scheduler
Durable Task separa il coordinamento dall'esecuzione degli effetti collaterali. L'orchestrator descrive la sequenza logica; le activities effettuano I/O, chiamate LLM e accessi al database. Il Durable Task Scheduler (DTS) mantiene lo stato durevole delle istanze e rende disponibile il lavoro ai worker.
La cronologia è un log di eventi, non un dump della memoria CLR. Al riavvio il runtime riesegue il codice dell'orchestrator utilizzando i risultati già registrati nella history. Non ripristina lo stack di un thread sospeso.
Replay deterministico
Durante il replay, il codice dell'orchestrator deve produrre la stessa sequenza logica di decisioni. Chiamate dirette a DateTime.UtcNow, generatori casuali, HTTP, query SQL e inferenza LLM possono compromettere questa proprietà. Usare invece il tempo logico fornito dal contesto, timer durevoli e attività per gli effetti esterni.
// Schema delle responsabilità: esempio illustrativo.
// L'I/O deve avvenire in una activity, NON nell'orchestrator.
public override async Task<IncidentOutcome> RunAsync(
TaskOrchestrationContext context, IncidentRequest request)
{
var evidence = await context.CallActivityAsync<EvidenceRef>(
"CollectEvidence", request);
var proposal = await context.CallActivityAsync<ProposalRef>(
"GenerateAndPersistProposal",
new ProposalRequest(request.IncidentId, evidence));
await context.CallActivityAsync(
"OpenApproval", proposal);
// L'evento segnala che la decisione potrebbe essere disponibile.
// L'activity seguente legge la decisione AUTOREVOLE.
await context.WaitForExternalEvent<string>(
$"approval:{proposal.ApprovalId}");
var decision = await context.CallActivityAsync<ApprovalDecision>(
"ReadCommittedDecision", proposal);
if (!decision.Approved || decision.ProposalHash != proposal.Hash)
return new IncidentOutcome("Rejected");
return await context.CallActivityAsync<IncidentOutcome>(
"ExecuteApprovedAction", proposal);
}
Nota sul codice: lo snippet mostra le boundary architetturali, non un progetto compilabile autonomamente: DTO, registrazioni, autenticazione e policy di timeout sono intenzionalmente omessi. La gestione di timeout e segnali duplicati va implementata prima dell'uso in produzione.
3. Quattro crash window che cambiano tutto
| Interruzione | Comportamento corretto | Responsabile |
|---|---|---|
| Dopo il completamento registrato di un'activity | Il replay riutilizza il risultato registrato. | Durable Task |
| Dopo la risposta del modello, prima del commit del risultato | Recuperare la proposta già persistita con una chiave stabile; non sostituirla silenziosamente. | Adapter LLM + proposal store |
| Durante l'attesa di approvazione | Riprendere l'attesa senza occupare un thread; leggere la decisione persistita. | Scheduler + approval store |
| Dopo l'esecuzione del tool, prima dell'acknowledgement | Riconciliare l'operazione con il sistema remoto prima di tentare un retry. | Tool adapter + operation journal |
4. Exactly-once: la garanzia che non si può dedurre dalla history
La history permette al runtime di evitare la riesecuzione di activity già confermate. Non può impedire che un effetto esterno sia avvenuto prima della conferma. Perciò, nei sistemi distribuiti, le chiamate esterne devono essere considerate potenzialmente ripetibili.
La soluzione è combinare identificativo stabile, idempotency key, fingerprint dei parametri e reconciliation. Se la API remota non espone uno stato interrogabile né supporta deduplicazione, non si può promettere exactly-once soltanto aggiungendo Durable Task.
Invarianti da applicare
- La stessa chiave di operazione con parametri differenti deve essere rifiutata.
- Un'approvazione deve riferirsi alla versione esatta della proposta, non genericamente all'incidente.
- Una proposta approvata può diventare non più sicura: la policy deve essere rivalutata prima dell'esecuzione.
- Un esito
Unknownnon deve essere trasformato automaticamente inFailed. - Il risultato remoto deve poter essere correlato all'operation ID.
5. Human-in-the-Loop: approvare un'azione immutabile
L'approvazione umana non è un semplice evento UI. È una transazione applicativa autenticata. L'endpoint di approvazione deve verificare l'identità del revisore, il suo ruolo, la scadenza, lo stato corrente e l'hash della proposta. Solo dopo aver registrato una decisione terminale può inviare il segnale al workflow.
public sealed record RemediationProposal(
string IncidentId,
string ProposalId,
string OperationId,
string ActionName,
string CanonicalArgumentsJson,
string Sha256,
DateTimeOffset ExpiresAt);
public sealed record ApprovalRecord(
string ProposalId,
string ProposalSha256,
string ReviewerId,
bool Approved,
DateTimeOffset DecidedAt);
Un token di approvazione non deve diventare una credenziale illimitata per gli strumenti. Il backend deve continuare ad applicare RBAC/ABAC e controllare gli argomenti al momento dell'esecuzione. L'evento durevole è un hint: il database delle decisioni rimane la fonte autorevole.
6. Laboratorio C#/.NET 10: simulare un crash e verificare il recovery
Prima di introdurre un runtime specifico, isoliamo la proprietà più importante: una chiamata remota con acknowledgement perso deve essere riconciliata, non eseguita nuovamente. Il seguente laboratorio è autonomo e compilabile con .NET 10; simula una API remota e un crash mediante due esecuzioni del programma. Non è un emulatore Durable Task: verifica deliberatamente soltanto l'invariante di idempotenza.
6.1 Creazione del progetto
dotnet new console -n DurableRecoveryLab -f net10.0
cd DurableRecoveryLab
6.2 Sostituire Program.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
const string journalPath = "operation-journal.json";
const string operationId = "incident-42:proposal-7:remediate";
const string payload = "restart-service:service-A";
var fingerprint = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
var journal = File.Exists(journalPath)
? JsonSerializer.Deserialize<Dictionary<string, string>>(
File.ReadAllText(journalPath))!
: new Dictionary<string, string>();
// Il journal rappresenta lo stato AUTOREVOLE della API remota
// e sopravvive a un riavvio del processo in questo laboratorio.
if (journal.TryGetValue(operationId, out var previous))
{
if (previous != fingerprint)
throw new InvalidOperationException(
"Operation ID reused with different arguments.");
Console.WriteLine("RECONCILED: operation already committed.");
return;
}
Console.WriteLine("Executing external operation once...");
// Simulazione di commit remoto: file atomico nel filesystem locale.
journal[operationId] = fingerprint;
var temp = journalPath + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(journal));
File.Move(temp, journalPath, overwrite: true);
if (args.Contains("--crash-after-commit"))
{
Console.WriteLine("SIMULATED CRASH: acknowledgement lost.");
Environment.FailFast("Crash after remote commit");
}
Console.WriteLine("COMMITTED: operation completed.");
Limite esplicito: il file locale rappresenta una simulazione di ledger remoto, non un registro transazionale multi-processo. Per un ambiente reale occorre una transazione nel sistema autorevole, vincolo univoco sull'operation ID, fingerprint immutabile e, dove necessario, un meccanismo di fencing.
6.3 Fault injection
# Prima esecuzione: commit e crash intenzionale.
dotnet run -- --crash-after-commit
# Seconda esecuzione: riconciliazione, nessun secondo commit.
dotnet run
La prima esecuzione termina intenzionalmente con errore dopo aver scritto il journal. La seconda trova la stessa operazione già registrata e stampa RECONCILED. È il comportamento che l'adapter di una activity dovrebbe ottenere interrogando una API remota. Eliminando il file si riparte da uno stato iniziale.
6.4 Test aggiuntivi
| Test | Invariante | Risultato atteso |
|---|---|---|
| Crash prima del commit | Non esiste alcuna operazione registrata | Il retry può iniziare la prima esecuzione |
| Crash dopo il commit | L'operation ID esiste | Riconciliazione senza nuovo effetto |
| Stessa chiave, payload diverso | Fingerprint immutabile | Rifiuto della richiesta |
| Due worker concorrenti | Deduplicazione atomica lato server | Un solo commit; richiede DB reale |
| Approvazione scaduta | Deadline e versione della proposta | Nessuna esecuzione |
7. Dal laboratorio a Durable Task Scheduler
Per utilizzare la vera orchestrazione durevole si può seguire il quickstart Microsoft per il portable Durable Task SDK, che configura l'emulatore DTS e un worker .NET. Le dipendenze principali appartengono alle famiglie Microsoft.DurableTask.*; per l'integrazione con DTS sono disponibili i pacchetti client e worker AzureManaged. Le versioni e i comandi di avvio dell'emulatore devono essere allineati al quickstart ufficiale, poiché la toolchain è in evoluzione.
# Esempio di pacchetti per un host .NET standalone.
dotnet add package Microsoft.DurableTask.Client.AzureManaged
dotnet add package Microsoft.DurableTask.Worker.AzureManaged
dotnet add package Microsoft.Extensions.Hosting
La stringa di connessione usata dagli esempi locali è:
Endpoint=http://localhost:8080;TaskHub=default;Authentication=None
Questa configurazione senza autenticazione è appropriata soltanto per un emulatore locale isolato. In Azure utilizzare autenticazione Entra ID e accessi di rete adeguati.
La trasposizione del laboratorio richiede tre activity distinte: GenerateAndPersistProposal, ResolveApproval e ExecuteOrReconcile. L'ultima deve ricevere un operation ID determinato prima del primo tentativo e verificare il journal remoto a ogni redelivery. La sola history DTS non può sostituire questo controllo.
8. Microsoft Agent Framework: Durable Agents e Durable Workflows
La Durable Extension di Microsoft Agent Framework (MAF) integra il modello agentico con Durable Task. È utile distinguere due astrazioni:
- Durable Agent: conserva sessioni e contesto conversazionale attraverso più richieste e riavvii.
- Durable Workflow: esegue grafi di executor/agenti con checkpoint, routing, fan-out/fan-in e passaggi di approvazione.
Per il modello self-hosted, la documentazione indica il pacchetto Microsoft.Agents.AI.DurableTask e le API ConfigureDurableAgents e ConfigureDurableWorkflows. Sono disponibili anche hosting Azure Functions e campioni ufficiali per console app.
// Frammento dalla configurazione self-hosted documentata da Microsoft.
// Presuppone un oggetto `agent` già creato e i package richiesti.
string connectionString =
Environment.GetEnvironmentVariable(
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options => options.AddAIAgent(agent),
workerBuilder: builder =>
builder.UseDurableTaskScheduler(connectionString),
clientBuilder: builder =>
builder.UseDurableTaskScheduler(connectionString));
})
.Build();
await host.StartAsync();
La differenza è significativa: il primo laboratorio mostrava come coordinare in modo durevole attività attorno a un agente; la Durable Extension aggiunge persistenza anche alle sessioni e ai workflow dell'Agent Framework. Non elimina comunque la necessità di autorizzare tool call e riconciliare gli effetti esterni.
9. Durable orchestration, queue worker o state machine SQL?
| Scenario | Scelta da valutare | Motivazione |
|---|---|---|
| Task indipendente di pochi secondi | BackgroundService / queue | Minore complessità |
| Processo con più step e attese di giorni | Durable Task | Timer, eventi, history e recovery |
| Stato già gestito da un motore transazionale consolidato | State machine SQL + outbox | Evitare una seconda fonte di verità |
| Agenti con sessioni e workflow distribuiti | MAF Durable Extension | Persistenza del modello agentico |
| Operazione remota potenzialmente duplicabile | Idempotency + reconciliation | Necessaria in ogni architettura |
Durable Task diventa conveniente quando il codice necessario a ricordare cosa fare dopo sta diventando un sistema a sé. Non è una scelta obbligata per ogni async/await.
10. Osservabilità: misurare il comportamento, non solo l'esito
Una dashboard che mostra Completed non prova che l'operazione esterna sia stata eseguita una sola volta. La telemetria dovrebbe correlare OrchestrationInstanceId, IncidentId, ProposalId, OperationId e l'eventuale receipt remoto.
- Workflow: durata totale, tempo in attesa di approvazione, replay e retry.
- LLM: modello, token, latenza, costo e fingerprint della proposta.
- Tools: esecuzioni richieste, esiti incerti, reconciliation e deduplicazioni.
- Governance: identità del revisore, versione della policy, decisione e timestamp.
Non inserire automaticamente prompt completi, credenziali o documenti sensibili nei log. Anche la history e le tracce dell'agente sono dati da proteggere e sottoporre a retention.
11. Deployment, versioning e sicurezza
Un workflow può rimanere attivo mentre il codice viene aggiornato. Modificare la sequenza delle activity o le condizioni di branching può rendere incompatibile il replay di istanze esistenti. Usare versioning delle orchestrazioni, deployment coordinati e test di replay. Il repository durabletask-dotnet documenta supporto per orchestrator versionati e migrazione attraverso ContinueAsNew in punti sicuri.
Per gli agenti in produzione è inoltre opportuno implementare: admission idempotente delle richieste, outbox per il passaggio fra database applicativo e scheduler, timeout e scadenze fisse, separazione delle identità utente/servizio, autorizzazione server-side, cifratura dei dati persistiti e limiti di concorrenza.
12. Checklist di fault injection prima del go-live
- Terminare il worker dopo il salvataggio della proposta LLM ma prima dell'acknowledgement dell'activity.
- Inviare due volte la stessa approvazione e verificarne la deduplicazione.
- Inviare una approvazione riferita a un hash diverso e verificarne il rifiuto.
- Interrompere il worker durante l'attesa e riprendere su un'altra istanza.
- Simulare una risposta HTTP persa dopo il commit remoto.
- Ritardare un evento oltre la deadline e verificare la decisione autorevole.
- Distribuire una nuova versione del worker con workflow ancora attivi.
- Verificare che log e dashboard non espongano segreti o dati sensibili.
13. Conclusioni
Un Durable AI Agent non è semplicemente un LLM con memoria persistente. È un sistema distribuito che coordina attività nondeterministiche, decisioni umane e side effect esterni attraverso interruzioni e retry.
La proprietà da progettare non è «il workflow riparte», ma «dopo il riavvio viene eseguita soltanto l'operazione effettivamente autorizzata, e ogni effetto incerto viene riconciliato prima di procedere».
Durable Task risolve la persistenza del coordinamento. Microsoft Agent Framework fornisce le astrazioni agentiche. Idempotenza, autorizzazione, ledger applicativo e riconciliazione restano responsabilità dell'architettura. È questa separazione che rende un agente affidabile.
Riferimenti tecnici
- Microsoft .NET Blog — Durable Task in the Real World (12 ottobre 2026).
- Microsoft Learn — Durable Task for AI Agents.
- Microsoft Learn — Microsoft Agent Framework Durable Extension.
- Microsoft Learn — Portable Durable Task SDK quickstart.
- GitHub — microsoft/durabletask-dotnet.
- GitHub — Microsoft Agent Framework Durable Extension e sample.
Nessun commento:
Posta un commento