Orleans × Aspire : le silo orchestré, les identités générées

// Parameters
var BATCH_MODE = "true";

Ce notebook est le second volet Orleans de la série The Unexpected AI Stack: C#/.NET (EPIC #10473) — l’axe successeur nommé par le registre des axes : co-host Aspire AppHost + IGrainWithGuidKey.

01-Orleans-Grains-Agents montrait le modèle acteur en lui-même (silo co-hébergé dans le process du lab, clés string). Ce volet change les deux côtés de l’équation :

01 (précédent) 02 (ce notebook)
Qui lance le silo le notebook (dotnet run ad hoc) l’AppHost Aspire : le silo est une ressource d’une application distribuée déclarée en C#
Identité des conversations clé nommée ("session-alpha") IGrainWithGuidKey : identité générée (Guid.NewGuid()), aucun registre de noms

Pourquoi ces deux changements

  1. Orchestrer, pas seulement lancer. Le lab 01 démarrait son silo comme un process jetable. En production le silo fait partie d’une application distribuée (avec ses dépendances, sa config, son observabilité). Aspire déplace cette topologie dans du code C# (apphost.cs) — là où l’écosystème Python assemble des docker-compose.yml, scripts et variables d’environnement.
  2. Générer l’identité. Une clé nommée exige d’avoir choisi le nom — et deux appelants qui choisissent le même nom partagent un grain par accident. Guid.NewGuid() rend la collision structurellement impossible : chaque conversation ouverte est un grain neuf ; retenir le Guid, c’est retenir l’adresse de l’état.
// Helper partagé entre les cellules (.NET Interactive : un static class persiste).
// Cellule = DECLARATION SEULE : en C# top-level, aucune instruction ne peut
// suivre une declaration de type dans la meme unite de compilation.
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using System.Threading;

public static class LabShell
{
    public static readonly string AspireCmd = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        ".dotnet", "tools",
        OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");   // CLI Aspire (dotnet tool global)
    public static readonly string LabDir = FindLabDir();
    public static readonly string AspireLogDir = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire", "logs");

    public static string FindLabDir()
    {
        // Le notebook vit dans Integrations-DotNet/Orleans/, le lab juste a cote.
        var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "MyIA.AI.Notebooks")))
            dir = dir.Parent;
        return Path.Combine(dir!.FullName, "MyIA.AI.Notebooks", "GenAI",
            "Integrations-DotNet", "Orleans", "OrleansAspireLab");
    }

    // Execute une commande, capture stdout+stderr, retourne la sortie.
    // Un .cmd EXIGE cmd.exe (Windows) ; dotnet, et aspire hors Windows, se lancent directement.
    public static string Run(string fileName, string arguments, string workingDir, int timeoutSeconds = 180)
    {
        var useCmd = fileName.EndsWith(".cmd");
        var psi = new ProcessStartInfo(useCmd ? "cmd.exe" : fileName,
                                      useCmd ? $"/c {Path.GetFileName(fileName)} {arguments}" : arguments)
        {
            WorkingDirectory = workingDir,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };
        // Hors Windows, sans terminal, la CLI Aspire ne connait pas la largeur de la console
        // et reduit chaque ligne a une ellipse : on la fixe.
        if (fileName == AspireCmd && !OperatingSystem.IsWindows())
            psi.Environment["ASPIRE_CONSOLE_WIDTH"] = "200";
        using var proc = Process.Start(psi)!;
        string output = proc.StandardOutput.ReadToEnd();
        string err = proc.StandardError.ReadToEnd();
        if (!proc.WaitForExit(timeoutSeconds * 1000)) { proc.Kill(); throw new TimeoutException($"{fileName} {arguments}"); }
        var text = output + (err.Trim().Length > 0 ? "\n[stderr] " + err.Trim() : "");
        // Nettoyer les sequences ANSI de la CLI Aspire.
        return Regex.Replace(text, @"\x1b\[[0-9;]*m", "");
    }

    public static string RunClient(string scenario, string? extra = null)
        => Run("dotnet", $"run --no-build --project ClientDriver -- {scenario}{(extra is null ? "" : " " + extra)}", LabDir);

    // --nologo : sur une machine neuve, sortie redirigee, la banniere de premier usage de la CLI
    // plante (Aspire.Cli 13.4.6) et sa sentinelle n'est jamais posee.
    public static string RunAspire(string arguments, int timeoutSeconds = 180)
        => Run(AspireCmd, $"{arguments} --nologo", LabDir, timeoutSeconds);

    // Attendre que le gateway Orleans du silo ecoute (le silo met ~10 s a demarrer).
    public static bool WaitForGateway(int port, int timeoutSeconds = 90)
    {
        var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
        while (DateTime.UtcNow < deadline)
        {
            try
            {
                using var tcp = new TcpClient();
                var connect = tcp.BeginConnect("127.0.0.1", port, null, null);
                if (connect.AsyncWaitHandle.WaitOne(1500) && tcp.Connected) return true;
            }
            catch { /* pas encore pret */ }
            Thread.Sleep(1200);
        }
        return false;
    }

    // Cycle complet : couper un eventuel run precedent, demarrer l'AppHost, attendre le gateway.
    // C'est LE geste pedagogique du lab : modifier Grains.cs n'a d'effet qu'apres relance du silo.
    public static (string sortie, bool gatewayPret) RestartSilo()
    {
        try { RunAspire("stop"); } catch { /* rien ne tournait : ignorer */ }
        var sortie = RunAspire("run --detach", 150);
        var pret = WaitForGateway(30000);
        return (sortie, pret);
    }
}
using System.IO;
// Chemin affiche RELATIF au depot : aucun chemin machine dans les sorties commitees.
var labRelatif = LabShell.LabDir.Substring(LabShell.LabDir.IndexOf("MyIA.AI.Notebooks"));
Console.WriteLine($"lab : {labRelatif}");
Console.WriteLine($"CLI Aspire presente : {File.Exists(LabShell.AspireCmd)}");
lab : MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Orleans/OrleansAspireLab
CLI Aspire presente : True

1. Construire le lab

Le lab OrleansAspireLab/ contient quatre pièces réelles (pas de réimplémentation) :

Pièce Rôle
apphost.cs l’AppHost Aspire (fichier #:sdk, modèle .NET 10) : déclare le service Orleans et le silo comme ressources
Silo/ le silo Orleans 10.3.1 (Microsoft.Orleans.Server), projet console orchestre par l’AppHost
ClientDriver/ le client (Microsoft.Orleans.Client), process séparé du silo
Grains/ les interfaces + implémentations des grains, partagées silo ↔︎ client
// Vrai build MSBuild des projets du lab (le silo et le client).
using System.Linq;
var buildSilo = LabShell.Run("dotnet", "build Silo/Silo.csproj -v q", LabShell.LabDir);
var buildClient = LabShell.Run("dotnet", "build ClientDriver/ClientDriver.csproj -v q", LabShell.LabDir);
foreach (var (nom, log) in new[] { ("Silo", buildSilo), ("ClientDriver", buildClient) })
{
    var lignes = log.Split('\n').Where(l => l.Contains("error") || l.Contains("Erreur") || l.Contains("error CS")).Take(3).ToList();
    Console.WriteLine($"[{nom}] {(lignes.Count == 0 ? "build OK" : string.Join(" | ", lignes))}");
}
[Silo]     0 Erreur(s)
[ClientDriver]     0 Erreur(s)

Lecture du build

Deux builds verts signifient : les proxys de grains sont générés à la compilation (Microsoft.Orleans.Sdk sur Grains/), et silo comme client partagent le même contrat compilé. Le client est un process distinct — c’est la différence structurelle avec le lab 01, où silo et client vivaient dans le même process.

2. Démarrer l’application distribuée

Le silo ne se lance plus « à la main » : on démarre l’AppHost, qui déclare la topologie (le service Orleans + le projet silo) et laisse l’orchestrateur Aspire (DCP) faire le reste — lifecycle, logs, dashboard.

// Demarrage orchestre : aspire run --detach rend la main une fois l'AppHost pret.
using System.Text.RegularExpressions;
var (sortieAspire, gatewayPret) = LabShell.RestartSilo();
var urlDashboard = Regex.Match(sortieAspire, @"https://localhost:(\d+)/login").Groups[1].Value;
var pidAspire = Regex.Match(sortieAspire, @"PID\s*:\s*(\d+)");
Console.WriteLine(sortieAspire.Split('\n').Where(l => l.Trim().Length > 0).TakeLast(6).ToArray()
    .Select(l => Regex.Replace(l, @"\?t=[0-9a-f]+", "?t=<jeton local omis>"))
    .Select(l => Regex.Replace(l, @"file:///\S+", "<journal CLI local>"))
    .Select(l => Regex.Replace(l, @"[A-Za-z]:[/\\]\S*", m => m.Value.Contains("aspire") ? "<home>" : m.Value))
    .Select(l => l.TrimEnd()).Aggregate((a, b) => a + "\n" + b));
Console.WriteLine($"\n[gateway] ecoute sur 30000 : {gatewayPret}");
Console.WriteLine($"[dashboard] https://localhost:{urlDashboard} (URL sans le jeton de session local)");
Démarrage de l’application Aspire en arrière-plan...
           AppHost:  apphost.cs
   Tableau de bord:  d=1199398943;https://localhost:17193/login?t=<jeton local omis>\https://localhost:17193/login?t=<jeton local omis>;;\
          Journaux:  d=710773234;<journal CLI local>
               PID:  22954
✅ AppHost a démarré correctement.

[gateway] ecoute sur 30000 : True
[dashboard] https://localhost:17193 (URL sans le jeton de session local)

Lecture du démarrage

L’orchestrateur a fait trois choses qu’aucun script du notebook n’a eu à faire : générer l’AppHost, démarrer le dashboard, et lancer le silo comme ressource managée. La preuve que la déclaration AddOrleans(...) du apphost.cs est bien résolue vers notre process silo se lit dans le journal de l’orchestrateur :

// Preuve d'orchestration : les evenements de l'AppHost (journal de la CLI Aspire).
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
var dernierLog = new DirectoryInfo(LabShell.AspireLogDir)
    .GetFiles("cli_*detach-child*.log")
    .OrderByDescending(f => f.LastWriteTimeUtc)
    .First();
Console.WriteLine($"[journal] {dernierLog.Name}");
// Lecture PARTAGEE (FileShare.ReadWrite) : le process CLI detache ecrit encore dans ce journal.
var lignesJournal = new List<string>();
using (var fs = new FileStream(dernierLog.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var reader = new StreamReader(fs))
    while (!reader.EndOfStream) lignesJournal.Add(reader.ReadLine() ?? "");
foreach (var l in lignesJournal
             .Where(l => l.Contains("changed state") || l.Contains("silo-orleans-gateway")
                      || l.Contains("silo-orleans-silo") || l.Contains("'silo' is ready"))
             .Select(l => Regex.Match(l, @"\] \[AppHost\]\s+(.*)$").Groups[1].Value.Trim())
             .Where(l => l.Length > 0)
             .Take(8))
    Console.WriteLine($"[evenement] {l}");
[journal] cli_20261004T014914738_detach-child_fb9e255b43734048920042df3f566eed.log
[evenement] Resource silo/silo-zehdfeny changed state: Starting
[evenement] Resource aspire-dashboard/aspire-dashboard-bvqkwthg changed state: Starting
[evenement] Resource aspire-dashboard/aspire-dashboard-bvqkwthg changed state: Running
[evenement] Resource silo/silo-zehdfeny changed state: Running
[evenement] Resource 'silo' is ready.
[evenement] Service silo-orleans-gateway is now in state Ready  {"Service": "/silo-orleans-gateway", "Reconciliation": 20}
[evenement] Service silo-orleans-silo is now in state Ready {"Service": "/silo-orleans-silo", "Reconciliation": 22}

Interprétation — ce que la déclaration a acheté

  1. Resource silo ... changed state: Starting → Running : le silo est passé par le lifecycle de l’orchestrateur, pas par un Process.Start du notebook. Le tuer, le redémarrer, lire ses logs : tout passe par l’AppHost (aspire stop / aspire run).
  2. Service silo-orleans-gateway is now in state Ready (et son pendant silo-orleans-silo) : le service Orleans déclaré par AddOrleans("agent-cluster") a été résolu vers le process silo. C’est le fil WithReference(orleans) : la déclaration nomme le cluster, la référence l’attache au projet.
  3. Une précision honnête : Aspire.Hosting.Orleans n’embarque aucun runtime Orleans — son paquet NuGet ne référence aucun package Microsoft.Orleans.* (on le mesure dans la cellule de garde SOTA plus bas). C’est un modèle de déclaration (identifiants de cluster, clustering, providers de stockage par connection strings) ; le runtime, lui, vit dans Silo/ via Microsoft.Orleans.Server. La ligne de parité visée reste : la topologie de l’application vit dans du code C# typé, pas dans un YAML + des scripts.

3. Identités générées — le scénario

Le ClientDriver ouvre deux conversations sans leur donner de nom : chacune reçoit un Guid.NewGuid(). Puis il rouvre une référence sur le Guid de la première, et cumule des tokens sur un compteur à clé nommée (le nom du modèle) — les deux familles de clés du lab, côte à côte.

var demoOutput = LabShell.RunClient("demo");
Console.WriteLine(demoOutput.TrimEnd());
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/user/wt-aspire/MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Orleans/OrleansAspireLab
[client] connecte au gateway 30000, scenario=demo
[demo] conversation A : id=97ce4c00-9636-444d-8057-b95f398f7325 -> 2 tours
[demo] conversation B : id=45b55dbd-79df-4da2-b7e9-ff33c9e82f88 -> 1 tour
[demo] identites distinctes : True (aucun registre de noms sollicite)
[demo] nouvelle reference sur le meme Guid -> 2 tours (etat conserve, l'identite est l'adresse)
[demo] compteur 'qwen3-coder' (cle nommee) -> 860 tokens cumules
[demo] guid a reprendre : 97ce4c00-9636-444d-8057-b95f398f7325
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...

Lecture de la démo — quatre faits mesurés

  1. Deux Guid distincts, aucun registre sollicitité. Personne n’a choisi "session-alpha" : l’identité est née de l’ouverture de la conversation. Deux requêtes simultanées ne peuvent pas entrer en collision — c’est la garantie structurelle qu’une clé nommée ne fait qu’espérer.
  2. Une nouvelle référence sur le même Guid retrouve l’état (« 2 tours »). Le Guid est l’adresse : GetGrain<IConversationGrain>(guid) ne crée rien, il désigne.
  3. Le compteur 'qwen3-coder' cumule à travers les conversations : la clé nommée reste l’outil adapté quand l’identité préexiste (le nom d’un modèle existe indépendamment des sessions).
  4. Le client est un process différent du silo — chaque cellule ci-dessus a fait mourir un client et rouvert un autre. L’état, lui, n’a jamais bougé de place.
// Gardes : verifier les invariants de la sortie reelle ci-dessus (pas de nombre magique en prose).
using System.Text.RegularExpressions;
var guidExtrait = Regex.Match(demoOutput, @"guid a reprendre : ([0-9a-f\-]{36})").Groups[1].Value;
bool deuxGuids = demoOutput.Contains("identites distinctes : True");
bool etatConserve = demoOutput.Contains("etat conserve, l'identite est l'adresse");
bool compteurCumule = Regex.IsMatch(demoOutput, @"qwen3-coder' \(cle nommee\) -> \d+ tokens cumules");
Console.WriteLine($"guid distincts generes : {deuxGuids}");
Console.WriteLine($"relecture par Guid = etat conserve : {etatConserve}");
Console.WriteLine($"compteur a cle nommee cumule : {compteurCumule}");
Console.WriteLine($"guid extrait pour le scenario resume : {guidExtrait}");
if (!deuxGuids || !etatConserve || !compteurCumule || guidExtrait.Length != 36)
    throw new InvalidOperationException("invariant de la demo viole : relancer les cellules precedentes");
guid distincts generes : True
relecture par Guid = etat conserve : True
compteur a cle nommee cumule : True
guid extrait pour le scenario resume : 97ce4c00-9636-444d-8057-b95f398f7325

4. Reprendre une conversation par son identité

Le client qui a ouvert la conversation A est mort depuis la cellule précédente. On en lance un autre, qui ne connaît que le Guid — l’état doit être retrouvé, car il vit dans le silo orchestré, pas dans aucun des clients.

var resumeOutput = LabShell.RunClient("resume", guidExtrait);
Console.WriteLine(resumeOutput.TrimEnd());
bool repriseOk = resumeOutput.Contains("tours retrouves") && resumeOutput.Contains("etat vivait dans le silo");
Console.WriteLine($"\n[garde] reprise par identite : {repriseOk}");
if (!repriseOk) throw new InvalidOperationException("reprise par Guid echouee");
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/user/wt-aspire/MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Orleans/OrleansAspireLab
[client] connecte au gateway 30000, scenario=resume
[resume] conversation 97ce4c00-9636-444d-8057-b95f398f7325 -> 2 tours retrouves
[resume] | user : Genere un plan de cours sur Orleans
[resume] | assistant : 1. Grains 2. Identites 3. Co-host Aspire
[resume] le process client precedent est mort : l'etat vivait dans le silo
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...

[garde] reprise par identite : True

Interprétation

C’est la même propriété que le lab 01 (l’état survit à la référence), mais portée par un process client qui meurt réellement entre-temps — et un silo qui, lui, vit sa vie d’application orchestrée. Pour un workload IA : le serveur web qui répond à la requête n’a pas à détenir l’historique ; il lui suffit de retenir le Guid (dans l’URL, le cookie, la base de sessions) et l’adresse résout le reste.

5. Exercices

Les trois exercices se complètent dans OrleansAspireLab/Grains/Grains.cs. Piège du lab orchestré : le silo charge Grains.dll à son démarrage — compléter le source ne change rien tant que le silo n’est pas relancé. Chaque cellule ci-dessous relance donc le silo avant d’exécuter son scénario (c’est aussi la démonstration du coût du cycle : ~15 s par itération).

// Exercice 1 — temoin attendu tant que SummaryAsync est le stub.
var (_, pret1) = LabShell.RestartSilo();
Console.WriteLine($"[silo relance] gateway pret : {pret1}");
Console.WriteLine(LabShell.RunClient("ex1").TrimEnd());
[silo relance] gateway pret : True
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/user/wt-aspire/MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Orleans/OrleansAspireLab
[client] connecte au gateway 30000, scenario=ex1
[ex1] resume -> ""
[ex1] SummaryAsync est encore le stub : complete Grains.cs puis relance cette cellule
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...

Exercice 1 — résumé d’une conversation

Compléter ConversationGrain.SummaryAsync : retourner $"{nb} tours, dernier role : {role}" (chaîne vide si aucun tour). Le témoin attendu : "3 tours, dernier role : user" — puis compléter le source, relancer cette cellule, et la ligne de témoin laisse place au calcul.

// Exercice 2 — temoin attendu tant que RouteTokensAsync est le stub.
var (_, pret2) = LabShell.RestartSilo();
Console.WriteLine($"[silo relance] gateway pret : {pret2}");
Console.WriteLine(LabShell.RunClient("ex2").TrimEnd());
[silo relance] gateway pret : True
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/user/wt-aspire/MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Orleans/OrleansAspireLab
[client] connecte au gateway 30000, scenario=ex2
[ex2] cumul local A=0 (attendu 800), B=0 (attendu 900)
[ex2] RouteTokensAsync est encore le stub : complete Grains.cs puis relance cette cellule
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...

Exercice 2 — routage grain-à-grain, de l’identité générée vers la clé nommée

Compléter ConversationGrain.RouteTokensAsync(modelKey, tokens) : cumuler dans _tokenTotal et déléguer au grain ITokenCounterGrain identifié par modelKey (via la propriété protégée GrainFactory de la classe Grain). C’est la jonction des deux familles de clés : une conversation sans nom route sa consommation vers un compteur nommé — le motif d’une topologie d’agents sans service bus. Attendu : A=800, B=900 et le compteur global cohérent.

// Exercice 3 — temoin attendu tant que EstimateCostAsync est le stub.
var (_, pret3) = LabShell.RestartSilo();
Console.WriteLine($"[silo relance] gateway pret : {pret3}");
Console.WriteLine(LabShell.RunClient("ex3").TrimEnd());
[silo relance] gateway pret : True
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/user/wt-aspire/MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Orleans/OrleansAspireLab
[client] connecte au gateway 30000, scenario=ex3
[ex3] total=1625 tokens, cout estime a 40 cents/1k -> -1 cents
[ex3] EstimateCostAsync est encore le stub : complete Grains.cs puis relance cette cellule
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...

Exercice 3 — coût d’usage sur le grain à clé nommée

Compléter TokenCounterGrain.EstimateCostAsync(centsPerThousandTokens) : le coût total en cents, arrondi à 2 décimales (Math.Round). Le scénario cumule 1625 tokens à 40 cents/1k — attendu : 65,00 cents (l’écho de l’exercice 1 du notebook 01, ici sur un grain partagé entre conversations).

6. Garde SOTA — le vrai outil, mesuré

Le lab doit exécuter les vrais runtimes (Orleans 10.3.1, l’intégration Aspire officielle), pas une réimplémentation. La garde mesure aussi un fait structurel cité plus haut : le package Aspire.Hosting.Orleans ne dépend d’aucun package Orleans — c’est une déclaration, le runtime vit dans le silo.

// Garde SOTA : packages reels + absence de runtime Orleans dans le package de declaration.
using System.IO;
using System.Text.RegularExpressions;
string csprojSilo = File.ReadAllText(Path.Combine(LabShell.LabDir, "Silo", "Silo.csproj"));
string csprojClient = File.ReadAllText(Path.Combine(LabShell.LabDir, "ClientDriver", "ClientDriver.csproj"));
string apphost = File.ReadAllText(Path.Combine(LabShell.LabDir, "apphost.cs"));
bool orleansReel = csprojSilo.Contains("Microsoft.Orleans.Server") && csprojSilo.Contains("10.3.1")
                && csprojClient.Contains("Microsoft.Orleans.Client") && csprojClient.Contains("10.3.1");
bool aspireReel = apphost.Contains("Aspire.AppHost.Sdk") && apphost.Contains("AddOrleans")
               && apphost.Contains("Aspire.Hosting.Orleans");
// Mesure : le nuspec du package de declaration ne reference AUCUN package Orleans.
string nuspec = File.ReadAllText(Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
    ".nuget", "packages", "aspire.hosting.orleans", "13.4.6", "aspire.hosting.orleans.nuspec"));
bool declarationSansRuntime = !Regex.IsMatch(nuspec, @"id=""Microsoft\.Orleans\.");
Console.WriteLine($"runtime Orleans 10.3.1 (silo + client) : {orleansReel}");
Console.WriteLine($"AppHost Aspire + AddOrleans declares : {aspireReel}");
Console.WriteLine($"Aspire.Hosting.Orleans sans runtime Orleans embarque (nuspec) : {declarationSansRuntime}");
if (!orleansReel || !aspireReel) throw new InvalidOperationException("garde SOTA violee");
runtime Orleans 10.3.1 (silo + client) : True
AppHost Aspire + AddOrleans declares : True
Aspire.Hosting.Orleans sans runtime Orleans embarque (nuspec) : True

Le dashboard, vivant

Le dashboard Aspire tourne depuis le démarrage (URL capturée en §2). Une requête HTTP réelle atteste qu’il répond — dans un navigateur, ce même dashboard montre le silo en temps réel : état, logs de la ressource, télémétrie.

// GET reel sur le dashboard (certificat local auto-signé : accepter pour localhost).
using System.Net.Http;
var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, _, _, _) => true };
// Note : en soumission script (kernel .NET), la declaration "using var x = ..."
// est illegale en top-level — declaration simple, le kernel vit le temps de la cellule.
var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
int statutDashboard;
try
{
    var reponse = await http.GetAsync($"https://localhost:{urlDashboard}/");
    statutDashboard = (int)reponse.StatusCode;
}
catch (Exception ex) { Console.WriteLine($"[dashboard] injoignable : {ex.Message}"); statutDashboard = 0; }
Console.WriteLine($"[dashboard] HTTP {statutDashboard} (302 = redirection vers la page de login locale : le service repond)");
[dashboard] HTTP 200 (302 = redirection vers la page de login locale : le service repond)

7. Arrêter proprement

Dernier geste d’orchestration : couper l’application distribuée d’un seul appel — le dashboard et le silo, sans tuer de PID à la main.

using System.Text.RegularExpressions;
using System.Net.Sockets;
using System.Threading;
var stopOutput = LabShell.RunAspire("stop");
Console.WriteLine(Regex.Replace(stopOutput.Trim(), @"\n+", "\n"));
// Verifier que le gateway a bien ete libere.
Thread.Sleep(3000);
bool gatewayLibere = false;
try
{
    var tcp = new TcpClient();
    var c = tcp.BeginConnect("127.0.0.1", 30000, null, null);
    gatewayLibere = !(c.AsyncWaitHandle.WaitOne(1500) && tcp.Connected);
}
catch { gatewayLibere = true; }
Console.WriteLine($"[gateway] port 30000 libere : {gatewayLibere}");
Scanning for running AppHosts...
📦 Found running AppHost: apphost.cs
🛑 Sending stop signal to apphost.cs...
Stopping apphost.cs...
✅ apphost.cs stopped successfully.
[gateway] port 30000 libere : True

Ce que ce notebook ne couvre pas (limites honnêtes)

  • Persistence volatile : l’état des grains vit en mémoire du silo — aspire stop l’efface (la reprise du §4 ne survit pas à l’arrêt du silo). En production : WithGrainStorage côté AppHost + un fournisseur réel (la déclaration existe dans Aspire.Hosting.Orleans, le brancher est l’extension naturelle).
  • Mono-silo : UseLocalhostClustering en ports fixes. Le clustering multi-silo (et le placement automatique) reste hors scope — comme dans le notebook 01.
  • Ports fixes, pas --isolated : le lab écoute 30000/11111 en dur pour que le client s’y connecte simplement ; deux instances simultanées du lab entreraient en collision (l’axe --isolated du notebook Aspire 01 montre la solution du dépôt).
  • Dashboard non automatisable ici : le notebook atteste que le dashboard répond (HTTP 302) mais n’en capture pas les écrans ; la lecture temps réel des ressources reste un geste navigateur.

Conclusion

Lab 01 Ce notebook
Silo process jetable du notebook ressource orchestrée (lifecycle, journal, dashboard — événements mesurés en §2)
Identité conversation clé nommée choisie IGrainWithGuidKey générée — collision structurellement impossible, reprise par adresse
Client même process que le silo process séparé, mort-vivant entre scénarios — l’état ne bouge pas
Cycle exercice relancer dotnet run relancer l’AppHost (~15 s) : le coût du « silo managé » se paie en itération

La jonction des deux familles de clés (exercice 2) est le motif à retenir pour un workload IA : les conversations naissent anonymes et générées, les ressources partagées (modèles, quotas, caches) restent nommées — et le grain-à-grain fait le pont.

Où aller ensuite

Retour au sommet