Conversation
Rend `Runner::run()` de symfony/ai exécutable en code workflow : ses deux jambes non déterministes passent par le journal, la boucle se rejoue, la MessageBag se reconstruit seule. La couture basse est `ModelClientInterface`, pas `PlatformInterface` : `Provider` a déjà normalisé la conversation et les schémas d'outils en tableaux via `Contract::createRequestPayload()`, il ne reste que l'appel HTTP. Aucun mapping DTO de MessageBag / Content / Metadata n'est nécessaire. Mesuré : 6 réexécutions du code workflow pour 3 appels modèle et 2 appels d'outil ; payloads sortants identiques entre deux exécutions indépendantes. Branche de spike, non publiée. symfony/ai épinglé v0.13.0 : `Runner` est `@internal`, sa boucle est le contrat de déterminisme du rejeu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… nommés Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… au catalogue Le workflow Ai_DurableAgent est enregistré (tag durable.workflow sur src/Ai/Workflow/), l'outil weather passe par un service locator tagué, et l'appel modèle est servi par un client scripté : la démo tourne sans clé d'API et reste déterministe, donc rejouable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…= un signal Le fil est une projection du journal (chaque ai_model_invoke porte la conversation du tour en entrée), pas un état stocké à côté. Entre deux messages le workflow est suspendu, pas en attente dans un processus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HTTP L'event store in-memory faisait disparaître le WorkflowSignalReceived écrit par DeliverWorkflowSignalHandler : un signal envoyé depuis une autre requête n'atteignait jamais l'exécution suspendue. Backend DBAL (DUR030), journal en SQL, temporal.journal=false pour garder le cluster joignable (l'app déclare un handler Nexus). DoctrineBundle n'était pas enregistré ; files Messenger suffixées _spike, la base de dev partagée porte des messages d'une classe disparue qui bloquent tout consommateur. Vérifié : POST message -> signal -> le workflow suspendu reprend, appelle l'outil et répond. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, accord par signal Chaque appel d'outil passe par un ToolGuardInterface évalué en code workflow (donc pur, donc rejoué). La décision croise le mode et l'effet déclaré de l'outil (read/write/external), une liste de refus l'emportant toujours : mode | read | write | external auto | passe | passe | passe edition | passe | passe | demande standard | passe | demande | demande Une demande suspend le workflow sur le signal tool_decision — pas une attente dans un processus : l'accord peut arriver demain, après un redéploiement. Un refus n'est pas une exception, il redevient un ToolResult rendu au modèle, qui s'adapte. Le mode est lui-même de l'état de workflow, changeable par signal set_mode. Vérifié sur la stack : standard suspend send_email, le refus revient au modèle, set_mode auto le laisse passer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plus de bandeau séparé : la carte d'accord est une bulle du fil, posée à la place de l'appel d'outil qu'elle retient — donc après le message qui l'a déclenchée, là où on la cherche. Une fois la décision prise, la bulle laisse la place à l'appel puis à son résultat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…DurableAgent Ai_DurableChat était un sur-ensemble strict : une boucle de N tours dont le sample était le cas N=1 sans signaux. Deux noms pour un concept. Le type unique prend un prompt optionnel — avec maxTurns:1 il se termine et sert le sample, sans prompt il est un chat piloté par signaux. Corrige une régression que le passage en DBAL avait provoquée sur TOUS les samples : DurableSampleWorkflowRunner sonde Temporal dès qu'un WorkflowClient existe, or le cluster reste joignable pour Nexus alors que le journal est en SQL. Port Postgres épinglé aussi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…statut au-dessus du champ VO : ToolDefinition, PendingApproval, Transcript / TranscriptMessage / ToolCallRef / ToolStep, AgentMode et Duration typés jusque dans la projection. Les tableaux ne restent que là où ils sont le fil — payload du fournisseur, charge d'activité, schéma JSON d'outil — avec une fabrique de frontière (listFromWire / fromWire) à l'entrée. Échéance : approvalTimeoutSeconds, globale à l'instance d'agent, portée par un minuteur du journal (DUR032). Pas de réponse vaut refus, et le refus redevient un ToolResult rendu au modèle. La décision est inscrite dans la porte pour que le rejeu la relise au lieu de replanifier un minuteur déjà tiré. La projection lit désormais la charge de démarrage dans le store de métadonnées : un run dispatché n'écrit pas d'ExecutionStarted, seulement ses événements d'exécution. UI : le statut de l'agent passe juste au-dessus du champ de saisie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ToolApprovalGate::timeout() à côté de decide() : « refusé » et « jamais répondu » ont le même effet — l'outil ne part pas — mais ne disent pas la même chose. L'un est une décision, l'autre est son absence, et un journal qui les confond ne permet plus de savoir si quelqu'un a regardé. ApprovalOutcome porte les trois cas et la phrase que le modèle lit à la place du résultat. La première issue gagne : un signal arrivé après le tir de l'échéance ne ressuscite pas un appel déjà tranché, sinon l'ordre du journal donnerait le verdict inverse au rejeu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chaque enum répond à la question qu'on lui pose au lieu de se faire comparer : ApprovalOutcome::isApproved() / isExpired() ToolEffect::isHarmless() / isIrreversible() ToolDecision::isAllowed() / needsApproval() / isDenied() (le verdict devient privé) AgentMode::requiresApprovalFor(ToolEffect) Effet de bord voulu : la matrice mode × effet quitte ModeToolGuard pour AgentMode, où elle appartient — une garde ne devrait pas connaître la table, seulement demander au mode. ToolVerdict n'est plus manipulé hors de ToolDecision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le cluster redevient le journal. Ce qui change vraiment, c'est le chemin d'un signal : TemporalReadThroughEventStore::append() n'écrit que dans le cache local de la requête, donc un WorkflowSignalReceived posé par DeliverWorkflowSignalHandler y disparaît. Le contrôleur signale par WorkflowClient::signal() quand un client Temporal existe, et retombe sur le message Messenger sinon — la démo tourne sur les deux backends. Deux asymétries relevées en chemin : - la charge de démarrage ouvre le journal sur Temporal (ExecutionStarted) mais reste dans le store de métadonnées sur DBAL ; la projection lit les deux ; - ActivityScheduled porte les arguments de l'activité en mémoire, l'enveloppe ActivityMessage qui les contient sur Temporal. La projection descend jusqu'à la clé attendue au lieu de coder une profondeur. Vérifié sur la stack : sample synchrone, démarrage du chat, message, garde qui suspend en mode standard, refus qui revient au modèle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mencé » La dérivation comptait `null === lastModelCallId` comme un tour en cours, alors que c'est l'état de départ : avant le premier prompt le workflow est suspendu sur son signal. Un tour est en cours de trois façons, toutes lues au journal : un message signalé que le dernier appel modèle ne contient pas encore, un appel modèle planifié sans résultat, ou une réponse qui demande encore des outils. Aucun appel modèle du tout n'en est pas une. Côté page, la bulle optimiste disparaît dès que le journal porte le message plutôt qu'au prochain état inactif — sinon elle faisait doublon — et elle tient le statut « travaille » pendant le trajet du signal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'échéance était à 120 s — choisie pour que mes tests soient observables, pas pour un humain qui lit, réfléchit et change de fenêtre. La carte disparaissait pendant la lecture et l'agent répondait « refusé faute de validation » sans que personne n'ait rien refusé. Un quart d'heure, et surtout un compte à rebours visible sur la carte. L'instant d'expiration vient du minuteur du journal, pas de l'ouverture de la page : un rechargement ne remet pas l'échéance à zéro. Troisième asymétrie entre backends relevée par ce spike : TimerScheduled::scheduledAt() porte l'instant de tir dans le cœur et l'instant de départ dans le pont Temporal, dont le convertisseur laisse tomber startToFireTimeout et le résumé. Tant que l'attente dure le tir est forcément à venir — c'est ce qui permet de trancher sans deviner le backend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rs Temporal « suspendu — en attente d'un message » disait la mécanique interne au lieu de dire à l'utilisateur qu'il peut écrire. bin/spike-workers.sh démarre les deux workers dont la démo a besoin — séparés, parce que les transports Temporal sont des long-polls gRPC qui affament tout transport partageant leur boucle — et les détache (setsid) pour qu'ils survivent au shell qui les lance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le vocabulaire maison — rejeu, compensation, minuteurs, signaux, validations — fait d'excellentes blagues quand on le prend au premier degré. Une phrase par période de travail, renouvelée toutes les quatre secondes, jamais deux fois la même d'affilée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PHP meurt tous les ans depuis 1998, les elePHPants se collectionnent, personne ne se souvient de l'ordre des arguments d'array_filter, et PHP 6 n'est jamais sorti. Trente ans de mèmes valent mieux que douze jeux de mots sur le rejeu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neuf de plus, du même folklore : Xdebug qu'on attend, le baseline PHPStan qui grossit, le polyfill d'une fonction native, preg_match et ses trois valeurs de retour, et le @ qui éteint ce qu'on ne veut pas voir. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Une échéance réglée par l'autre branche fait annuler le minuteur perdant. Le replay repasse par cette annulation à chaque reprise, et un minuteur annulé n'a pas de verdict à annoncer : findTimerSlotResult() rend null, le minuteur revient en attente, l'annulation est redemandée. Le journal SQL s'en gardait déjà — EventStoreCommandBuffer::cancelTimer() relit le flux avant d'appendre, et son commentaire nomme exactement ce cas. Le pont Temporal ne s'en gardait pas, et le serveur ne pardonne pas : la tâche entière est rejetée avec BadCancelTimerAttributes: invalid history builder state for action: add-timer-canceled-event le worker meurt, la tâche est redélivrée, le worker meurt encore. Une seule exécution empoisonnait toute la file durable-journal et affamait toutes les autres. TemporalExecutionHistory lit désormais EVENT_TYPE_TIMER_CANCELED, qu'elle ignorait, et expose isTimerSettled() ; le tampon de commandes s'en sert comme il se sert déjà de l'historique pour cancelActivity(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'outil `demander_a_l_utilisateur` est le seul dont l'exécution est une suspension : son résultat n'est pas calculé, il est attendu. Le modèle propose une question, un libellé court et deux à quatre options ; le workflow suspend sur un signal `question_answered`, et la réponse revient au modèle à la place d'un résultat d'outil. C'est l'inverse de la garde, avec la même primitive. La garde décide si un outil que le modèle a choisi peut partir ; le guichet va chercher ce que le modèle ne sait pas. HumanQuestionDesk est le jumeau de ToolApprovalGate par la forme, pas par le rôle — et comme lui, la première réponse gagne, sinon l'ordre du journal donnerait le verdict inverse au rejeu. L'outil est toujours offert : un agent qui ne peut pas demander invente. Classé `read`, pour que personne n'ait à autoriser une question avant d'y répondre. `approvalTimeoutSeconds` devient `humanTimeoutSeconds` : c'est la même chose — combien de temps on attend un humain — et le nom ne parlait que d'une des deux attentes. Sans réponse, l'agent reçoit une phrase qui le lui dit et poursuit, au lieu de rester planté. Côté page, la carte questionnaire vit dans le fil comme la carte de validation, partage son compte à rebours, et laisse toujours une réponse libre : les options du modèle ne sont que des propositions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deux cas qui le prouvent plutôt que de l'affirmer : - en mode `standard`, poser une question passe sans validation — l'outil est classé `read`. Si la garde le retenait, le test resterait suspendu faute de signal `tool_decision`, et c'est exactement ce qu'il vérifie ; - une politique qui met `demander_a_l_utilisateur` en liste de refus l'interdit pour de bon, sans qu'aucun signal ne soit déposé. Si le guichet passait avant la garde, l'exécution suspendrait au lieu de rendre « interdit par la politique ». Vérifié par mutation : classer l'outil `external` fait rougir le premier avec une WorkflowStuckException sur la condition d'approbation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un vrai modèle décide seul de demander ; un modèle scripté a besoin qu'on le lui dise. « demande-moi… » pose une question à trois options, « choix multiple » dans la même phrase bascule la carte en sélection multiple. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le pont fournit la normalisation de la conversation, le catalogue et la conversion du JSON en résultat — tout cela est pur, donc tourne en code workflow et se rejoue. Seul son client HTTP est remplacé par DurableModelClient : lui seul sort du processus, et c'est précisément ce qui doit devenir une activité. C'est la couture identifiée dès le premier jour du spike, et elle tient avec un vrai pont. Deux choses que le pont a révélées : - son convertisseur n'est pas une fonction `tableau → résultat` : il commence par regarder le code HTTP, et exige une vraie ResponseInterface. Au rejeu il n'y a plus de socket, d'où JournaledHttpResponse — un 200 et un corps ; - ce qui est journalisé est donc toujours un succès. L'erreur HTTP appartient à l'activité : ModelInvocationActivityHandler relève sur tout code >= 400, pour qu'un 429 ou un 503 rencontre la politique de retentative de Durable (DUR011) plutôt que d'étrangler le convertisseur au rejeu. Sans MISTRAL_API_KEY, ModelClientFactory rend le client scripté : la démo tourne sans réseau ni clé, et tout le reste — journal, garde, guichet, échéances — se comporte à l'identique. ChatCompletionResultConverter disparaît : le convertisseur du pont le remplace, ce qui était sa raison d'être annoncée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt ce qu'il faisait `surveiller` est la seconde suspension de l'agent, après la question. Ce qui la lève ne vient pas d'un humain devant une carte mais du dehors : une supervision, un webhook, un autre agent signalent `alerte`. Le point qui la distingue d'un watcher de processus : **l'intention est écrite au journal au moment de l'inscription**, pas reconstruite au réveil. Trois jours et un redéploiement plus tard, ce que le modèle relit est « Alerte sur X : Y. Tu comptais : Z » — il n'a rien à se rappeler, le journal le lui dit. Sans alerte, l'échéance rend la main en rappelant l'intention elle aussi. WatchDesk est le troisième objet de cette forme après ToolApprovalGate et HumanQuestionDesk : une file par identifiant d'appel, réglée par signal, la première issue gagnant. Ils ne sont pas fusionnés parce que ce qu'ils portent diffère — une issue à trois cas, des réponses, une observation — et qu'un objet générique remplacerait trois types clairs par un `mixed`. La garde reste devant : `surveiller` est classée `read` (se mettre en veille n'écrit nulle part), et ce que l'agent fait *au réveil* repasse par elle comme n'importe quel appel. Côté page, la carte de veille montre ce qui est guetté et ce qui est prévu, et porte le bouton qui lève l'alerte — le rôle qu'une supervision tiendrait en production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La garde des outils protège des effets ; celle-ci protège de l'asphyxie. Une conversation durable grossit sans fin — c'est le prix du journal — et le jour où elle dépasse la fenêtre, l'agent ne rate pas un tour : il ne peut plus en faire un seul. Deux chemins, dans cet ordre : - **préventif** — avant chaque appel, ContextBudget::fit() ramène la conversation sous le plafond en abandonnant les tours les plus anciens. Par tours entiers, jamais par messages : un `assistant` qui demande des outils et les `tool` qui lui répondent forment un bloc, et couper au milieu laisse un résultat orphelin que les fournisseurs refusent. Le message système reste, et un marqueur dit combien de messages ont disparu ; - **réactif** — si le fournisseur compte autrement que nous, ContextOverflow le reconnaît et l'appel repart avec un budget de moitié. Ce n'est pas une panne mais une réponse : la rejouer donnerait le même verdict, donc l'activité la journalise comme une donnée au lieu de relever, et la politique de retentative de Durable ne s'en mêle pas (DUR011). La compaction est **pure** — pas d'horloge, pas de hasard, pas de résumé par le modèle. Elle tourne en code workflow, donc elle est rejouée ; un résumé produit par un modèle serait une seconde source de non-déterminisme là où on essaie justement d'en supprimer. Sa place est dans un `continueAsNew`, en charge de départ. Plafond assumé et testé : un tour à lui seul plus gros que la fenêtre n'est pas compacté — abandonner le message auquel il faut répondre n'aurait pas de sens. Il part tel quel, et c'est le chemin réactif qui reprend la main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Une conversation de démo n'atteint jamais 24 000 jetons. Le paramètre ouvre une conversation à budget minuscule : la compaction se déclenche alors en quatre ou cinq messages. Vérifié sur la stack, ?contexte=200 : après cinq tours, le payload parti à l'activité ne porte plus que le dernier tour — les précédents ont été abandonnés par tours entiers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ContextBudget manipulait `list<array{role, content, tool_calls…}>` en s'appuyant sur une
convention : ne jamais couper entre un appel d'outil et son résultat. Une convention se perd de
vue ; un type non.
Conversation porte le préambule système et une liste de Turn ; Turn est l'unité indivisible de
la compaction — le message de l'humain et tout ce que l'agent a produit en réponse. Le budget
n'a plus qu'à retirer des tours par le début, et `splitIntoTurns`/`flatten` disparaissent dans
la structure.
Les messages eux-mêmes restent des tableaux : c'est du fil, la forme appartient au fournisseur,
et la retyper reviendrait à réécrire son protocole. Fabrique de frontière dans les deux sens,
fromWire() / toWire().
Un piège que le type a rendu visible et que le test tient : seuls les messages système **de
tête** sont le préambule. Un marqueur de compaction posé au fil des tours remonterait en tête à
chaque passage, et la conversation finirait par en collectionner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deux bornes ferment un run d'agent, et aucune ne perd le fil. `idleTimeoutSeconds` — une heure de silence vaut une fin. L'attente sur le prochain message porte désormais une échéance ; passée, l'exécution se termine plutôt que de rester ouverte pour rien. Le minuteur est journalisé (DUR032), donc l'échéance survit au redémarrage comme l'attente elle-même. `rolloverAfterTurns` — au bout de N tours, `continueAsNew` ouvre un run neuf dans la même exécution. Ce n'est pas le journal qui borne : à une dizaine d'événements par tour, Temporal en tolère cent fois plus. C'est que chaque tour renvoie tout le sac au modèle, donc la dépense d'une conversation croît avec le carré de sa longueur. Ce qui se transporte est le fil *parlé* : un `role: tool` et un message d'assistant qui ne porte que des `tool_calls` racontent la mécanique du run qui s'achève ; les rejouer remplirait le suivant de bulles vides et de références d'appels qui n'existent plus. Deux pièges de projection, tous deux vérifiés par un test : - après un continue-as-new, le store de métadonnées porte encore la charge du **premier** run. L'événement du run courant passe donc devant, sinon la reprise afficherait un fil vide ; - le fil repris entre dans le sac dès le premier appel modèle. Sans le compter du côté des messages reçus, `messagesSignalled` (ce run seul) et `messagesSeenByModel` (le sac entier) cessent de parler de la même chose, et l'agent paraît au repos pendant qu'il travaille. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'événement `WorkflowContinuedAsNew` prouvait que la charge partait ; rien ne prouvait qu'elle arrivait. Le test pilote maintenant le run suivant avec cette charge et relit sa projection : le modèle doit voir le fil repris *et* le nouveau message. Vérifié par mutation — remplacer le fil repris par un tableau vide fait passer la projection de 4 messages à 2, et le test tombe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reprendre une conversation après une heure de silence en la rejouant mot pour mot ferait payer au premier tour tout ce que le run précédent avait déjà coûté. La reprise passe donc le fil au modèle, qui en rend un résumé, et c'est ce résumé qui ouvre le sac. L'appel sort du journal comme les autres — donc rejoué, donc payé une fois même si la reprise redémarre. Une activité, pas une boucle d'agent : il n'y a rien à outiller ici, et passer par `Runner` exposerait la compaction aux gardes et aux appels d'outils. Deux décisions qui ne sont pas cosmétiques, chacune tenue par un test : - l'activité s'appelle `ai_model_compact`, pas `ai_model_invoke`. La projection recompose le fil à partir du dernier `ai_model_invoke`, et la charge d'une compaction est justement la conversation qu'on remplace : sous le même nom, une reprise restée silencieuse réafficherait tout l'ancien fil. Vérifié par mutation — un seul nom, et l'affichage passe de 1 message à 3 ; - la projection lit le résumé et l'affiche dès avant le premier tour. Sinon la page montrerait le fil complet puis le verrait s'effondrer d'un coup, et le statut de l'agent compterait des messages que le modèle ne verra jamais. Le préfixe du résumé est une fabrique partagée (`TranscriptMessage::compaction`) et non deux `sprintf` : le workflow le construit pour le modèle, la projection le reconstruit pour l'affichage, et les deux doivent tomber sur le même texte. Le relais en cours de conversation, lui, transmet le fil tel quel : il a lieu au milieu d'un échange vivant, où perdre le détail se paierait tout de suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trois finitions sur la compaction, trouvées en tirant le fil du deuxième retour — le cas ordinaire de qui revient deux fois dans une conversation. L'étiquette « Résumé de notre conversation précédente » est pour l'humain. Elle repartait au modèle à la compaction suivante, qui résumait donc un résumé étiqueté. `stripped()` la retire à la frontière ; le test vise la charge envoyée, pas ce qui en revient — visé de l'autre côté il ne prouvait rien, le client scripté ne reproduisant pas l'imbrication. Un résumé vide n'est plus un résumé raboté : le fil repart tel quel. C'est le seul choix qui garde l'affichage et le sac d'accord, la projection retombant elle aussi sur le fil brut quand le journal ne porte pas de résumé exploitable. Et l'index de la compaction n'est lu que s'il existe, plutôt que d'indexer `$results` sur une clé nulle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ingue
Les samples et l'agent partageaient tout : servis par deux instances de la
même application, chacune répondant à tout, sur deux domaines. On voyait deux
fois la même chose.
Désormais une seule instance derrière les deux domaines, et c'est la
contrainte d'hôte qui tranche — posée au niveau de la classe, ce qui fait de
l'hôte une propriété de la facette et non de chaque route :
samples.durable.localhost → samples, tableau de bord, documentation
agent.durable.localhost → la démo d'agent durable
Un lien d'une facette vers l'autre sort en URL absolue tout seul : Symfony
voit que l'hôte diffère. Le « retour aux samples » du chat rend bien
`//samples.durable.localhost/`.
Les hôtes viennent de l'environnement (`APP_HOST_SAMPLES`, `APP_HOST_AGENT`)
et restent actifs en test — les neutraliser ferait passer des routes que le
navigateur ne verrait jamais. `DEFAULT_URI` fixant l'hôte du client à
`localhost`, c'est celui des samples : les tests fonctionnels existants
tombent du bon côté sans y toucher.
La racine du domaine de l'agent mène au chat, comme celle des samples mène
aux samples. Deux `/`, deux hôtes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le convertisseur ne lisait que `content` et `tool_calls`. Un `reasoning_content` dans la réponse journalisée était donc perdu : aucun `ThinkingResult` produit, aucun `Thinking` dans le sac, rien qui reparte au tour suivant. Sans exception et sans trace — le tour d'après partait simplement amputé. Chez un fournisseur qui vérifie ses blocs au renvoi (c'est ce à quoi sert le `signature` que porte `Thinking`), l'appel aurait échoué au deuxième tour. Le journal, lui, portait déjà ce qu'il fallait : l'activité rend `getData()`, donc le JSON brut entier. Le correctif est du code de workflow pur sur des données déjà là, pas un re-appel. Trois changements : - `ChatCompletionResultConverter` lit `reasoning_content` (ou `reasoning`) et rend un `MultiPartResult` quand il y a du raisonnement à côté du texte ou des appels d'outil. `Message::toContent()` le déplie, `AssistantMessageNormalizer` le remet en `reasoning_content` au tour suivant. - `DurableAgentWorkflow` prend la réponse par `asText()` sur un multi-parts : `getContent()` y rend un tableau, et le fil affichait « Array ». - `ScriptedChatModelClient` en émet, sinon rien ne l'exerce. Le raisonnement reste une fonction pure de la conversation reçue, donc rejouable. Deux tests, tous deux vérifiés par mutation : le raisonnement du tour N est présent dans la charge du tour N+1 (rouge sans le convertisseur), et la réponse reste du texte (rouge sans `asText()` — « Array »). Ce que ça ne fait pas : la signature. Aucun normaliseur de `ai-platform` ne l'écrit sur le fil — `AssistantMessageNormalizer` concatène le contenu dans `reasoning_content` et ne lit jamais `getSignature()`. Le rejeu signé suppose un bridge fournisseur qui remplace ce normaliseur ; aucun n'est installé ici. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Les blocs traversaient la frontière depuis le commit précédent, mais personne ne les voyait. Trois changements, un par étage. `TranscriptMessage` porte un `reasoning`, que `fromWire()` lit dans `reasoning_content` — la clé que le normaliseur de Symfony AI écrit déjà. `toWire()` **ne le rend pas**, et c'est délibéré : cette méthode alimente deux charges qui partent au modèle (la compaction, et le fil transmis par le relais). Y ajouter une clé changerait une charge sortante, donc ferait diverger un rejeu. Conséquence assumée : après un relais, le fil repris n'a plus ses blocs. `ChatTranscript` le lit à deux endroits, et il faut les deux : le raisonnement d'un tour passé revient dans la charge du tour suivant (le normaliseur l'y remet), celui du dernier tour n'a pas de tour suivant et ne vit que dans le résultat de l'activité. Le fil l'affiche dans un `<details>` replié, avant la bulle du tour — c'est ce qui l'a précédé, pas un commentaire d'après coup. Replié parce qu'un raisonnement se consulte, il ne se lit pas ; `<details>` parce qu'il fait déjà l'ouverture, le clavier et l'état. Deux tests de projection, vérifiés par mutation : couper la lecture du wire perd le tour passé, couper la lecture du résultat perd le dernier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seules les branches d'appel d'outil portaient un `reasoning_content` : la réponse finale arrivait donc sans, et le fil n'affichait de raisonnement que sur les tours mécaniques. Vérifié de bout en bout contre Temporal — les deux chemins de la projection rendent bien, celui du wire (tour passé) et celui du résultat d'activité (dernier tour). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment dans le même fil Les deux branches divergeaient depuis 64cdd1a, huit commits de chaque côté et les mêmes fichiers touchés. Quatre conflits de contenu, un de suppression. **Le convertisseur écrit à la main disparaît.** `ChatCompletionResultConverter` était supprimé côté questionnaires au profit du vrai pont Mistral. On prend la suppression : son `ResultConverter` fait tout ce que le nôtre faisait, et mieux — il rend un `ThinkingResult` et un `MultiPartResult` là où le nôtre bricolait. **Mais il change la forme du raisonnement, et ça se paie.** Vérifié dans le pont : `AssistantMessageNormalizer` de Mistral écrit les blocs en morceaux `thinking`/`text` dans `content`, et son docblock dit pourquoi — le `reasoning_content` du contrat générique est **refusé par un 422** « Extra inputs are not permitted ». Trois conséquences, toutes appliquées : - le modèle scripté émet désormais la forme Mistral sur ses tours parlés ; - `TranscriptMessage::splitContent()` lit les deux formes — la nouvelle, et l'ancienne pour les journaux écrits avant le pont : le fil est une projection, et changer de pont ne doit pas rendre illisibles les conversations d'avant ; - **un tour d'appel d'outil ne peut plus porter de raisonnement du tout.** `CompletionsConversionTrait::convertChoice()` rend un `ToolCallResult` nu dès que `finish_reason` vaut `tool_calls`. Un test le fige, pour que ça se voie le jour où le pont changera d'avis. `asText()` sur le résultat, ajouté pour les blocs de raisonnement, devient porteur : le convertisseur Mistral rend vraiment un `MultiPartResult`, et sans lui le fil afficherait « Array » à la place de la réponse. **Le reste des conflits.** `approvalTimeoutSeconds` devient `humanTimeoutSeconds` partout — une échéance humaine, validation comme question. `working` combine les deux lectures : quatre façons d'avoir un tour en cours (compaction comprise, du côté agent), et trois façons de ne pas travailler sans avoir fini — la balle est chez l'humain. `startAgent()` garde sa reprise et son fil, et gagne le levier `?contexte=N`. 126 tests, 280 assertions, vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le fil ne montrait rien entre le message envoyé et la réponse : seule une pastille, hors du fil, changeait de texte. Six attentes cohabitent maintenant — appel modèle, activité d'outil, compaction, validation, question, veille — et elles n'étaient pas distinguables. Deux signes, parce qu'il y a deux natures d'attente : - **une roue qui tourne** quand la machine calcule : la pastille, une bulle de travail en fin de fil (avec le même labeur que la pastille, `labeurCourant()` étant stable quatre secondes), et l'appel d'outil dont l'activité est partie sans revenir ; - **un battement** quand la balle est dans le camp de l'humain : validation, question, veille — carte et pastille. Mettre la roue sur une validation ferait patienter quelqu'un devant sa propre décision. C'est la même erreur qu'un tableau de bord qui cache une opération Nexus : il envoie chercher l'attente au mauvais endroit. `ToolStep` porte son `callId` jusqu'au fil : c'est ce qui permet de dire « cette activité-ci tourne encore ». Apparier sur le nom et les arguments marcherait jusqu'au premier agent qui appelle deux fois le même outil pareil. `prefers-reduced-motion` garde les deux signes et arrête le mouvement — c'est une information, pas une décoration. Vérifié à l'écran, worker d'activité éteint pour tenir l'état en vol. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un `<details>` déplié se refermait tout seul, et un champ à moitié rempli se vidait. La cause n'est pas le rythme du sondage : c'est `replaceChildren()` toutes les 1,2 s sur un fil où quelqu'un est en train d'agir. Un canal temps réel aurait refermé les mêmes blocs, sur un autre signal. Trois changements, aucun de transport : - **on ne redessine que si le journal a bougé.** Une empreinte du fil (le JSON plus le message optimiste local) coupe le redessin ; seuls la pastille et le labeur, qui changent d'eux-mêmes toutes les quatre secondes, sont remis à jour sur ce chemin ; - **une carte d'attente n'est construite qu'une fois.** Réutilisée d'un tour à l'autre par son `callId`, elle garde son texte tapé, son focus et son minuteur. C'est précisément l'endroit où l'on s'assoit et où l'on tape — la question de l'agent était inrépondable au clavier pour peu qu'on hésite une seconde ; - **le bloc de raisonnement retient son ouverture**, par le rang du message : le fil ne fait que s'allonger, un message déjà écrit ne change plus de place. Le tableau global de minuteurs disparaît : chaque minuteur vit et meurt avec sa carte, et n'existait sous cette forme que parce que le fil était rasé. Et le sondage lui-même devient moins bête : ETag et 304 sur `/transcript`, et une cadence qui s'étire jusqu'à 5 s quand rien ne bouge, se resserre à 700 ms dès qu'il se passe quelque chose ou dès qu'on agit — sans ce réveil, on répondrait à une question puis on attendrait la fin du repli. Ce que ça ne règle pas, et c'est dit dans le code : l'ETag économise la sérialisation et le transport, pas la lecture. `forExecution()` rejoue le flux d'événements à chaque appel. Seul un canal poussé l'éviterait — et pas sur ce serveur : `php -S` sans `PHP_CLI_SERVER_WORKERS` n'a qu'un processus, une connexion tenue ouverte bloquerait tout le reste. C'est Mercure, le jour où ce sera le coût qui gêne. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un concentrateur en service compose, port d'hôte fixe (33000) — la raison d'un hub à part plutôt qu'un endpoint SSE dans l'application tient en une ligne : `php -S` n'a qu'un processus, une connexion tenue ouverte y bloquerait tout le reste. Le hub encaisse les connexions, l'application ne fait que poster. **Une sonnette, pas un colis.** Le message publié dit « cette exécution a bougé », rien d'autre. La page va chercher le fil par le chemin qu'elle connaît déjà, et l'ETag rend gratuit le cas où rien n'a changé. Publier le fil aurait été une seconde projection, calculée une fois par abonné, dans un worker qui n'a que faire de l'affichage. **Deux crochets essayés et écartés**, tous deux parce que la démo est sur Temporal — et c'est le vrai résultat de ce commit : - `EventStoreInterface::append()` : le cycle de vie du magasin ne se déclenche pas sur une application Temporal, `ProjectingEventStore` le documente ; - `WorkerMessageHandledEvent` : les transports Temporal font le travail *dans* leur `get()` et ne rendent **aucune enveloppe**. Pas de message manipulé, donc pas d'événement. Le listener écrit pour ça a été retiré plutôt que gardé mort. Reste le contrôleur, où passe chaque action humaine. Faire porter l'identifiant d'exécution aux activités de l'agent rendrait la sonnerie possible côté machine, mais ce serait changer un contrat d'activité pour de l'affichage — et pendant qu'un tour est en cours la page sonde déjà à 700 ms, parce qu'elle se sait en attente. D'où le partage : **Mercure pour ce qui vient d'ailleurs** — un message envoyé depuis un autre onglet apparaît tout de suite dans les autres —, **le sondage pour l'exhaustivité**, replié à 15 s, qui rattrape ce que personne ne sonne : une échéance qui tombe, une clôture sur inactivité, un relais. Hub injoignable, la page retombe exactement sur le comportement d'avant : le sondage n'est jamais conditionné à la connexion. Vérifié contre Temporal : deux sonneries reçues sur le sujet de l'exécution, un message puis une décision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le signal `alerte` était joignable depuis toujours — n'importe quel service peut l'envoyer. Ce qui manquait, c'était de savoir **à qui** : `Watch` ne portait qu'une observation en texte libre, écrite par le modèle. L'agent écrivait « quand la livraison arrive », l'événement métier disait `commande.expediee`, et personne ne faisait le rapprochement — sans erreur, sans trace, l'agent dormait jusqu'à son échéance. **Le sujet est l'acte de design**, comme `ToolEffect` l'est pour la garde. `WatchSubject` est un enum, sa liste part au modèle dans le schéma de l'outil, et un sujet hors liste est **refusé visiblement** : l'agent reçoit le vocabulaire connu à la place d'un résultat d'outil et reprend la main, au lieu d'armer une veille que rien ne pourra jamais lever. **Aucun nouveau magasin.** `WatchRouter` reconstruit l'index à la demande : le catalogue d'exécutions dit lesquelles tournent, la projection du journal dit ce que chacune guette. Le journal reste la seule source de vérité, et il n'y a pas de table de veilles à garder cohérente — donc pas de ligne orpheline le jour où une exécution meurt sans se désinscrire. C'est un balayage, une lecture de journal par exécution vivante et par événement ; le commentaire nomme le plafond et la sortie (une table écrite par une activité, clé d'idempotence `executionId` + `callId`) sans la construire aujourd'hui. `AgentSignals` extrait le choix client/bus que le contrôleur portait seul : il a un second appelant, et deux copies auraient divergé au premier changement. La sonnerie Mercure y est indissociable du signal — un réveil venu du dehors est exactement ce pour quoi le concentrateur existe. `app:agent:evenement` est le levier de la démo. Une commande et pas une route : c'est la forme qu'une vraie intégration prend, et ça montre ce qu'il faut montrer — un fait levé **hors de toute requête web** qui réveille un workflow endormi. Six tests d'appariement sans Temporal (catalogue bouchonné, InMemoryEventStore, le vrai AgentSignals avec le MockHub du composant), plus le refus d'un sujet inconnu. Vérifié en vrai : veille armée sur `commande.expediee`, puis `app:agent:evenement commande.expediee numero=CMD-42` depuis un terminal — l'agent se réveille avec les précisions **et** l'intention qu'il avait écrite. Toutes les veilles du sujet sont réveillées, dans toutes les exécutions : un fait n'appartient à personne. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orité Première tranche des équipes d'agents. L'invariant d'abord, la mécanique ensuite : **l'autorité ne grandit pas par délégation.** Sans lui, déléguer est le chemin d'échappement de la garde : un agent en `standard` ne peut pas envoyer de courriel, mais il confierait la tâche à un sous-agent en `auto` qui l'enverrait. La garde ne serait pas contournée par une faille — elle serait devenue décorative. **Le plafond vit dans l'enum**, là où son propre docblock plaide que la règle du mode appartient : `AgentMode::strictest()` et `loosens()`. Le mode effectif d'un délégué est le plus strict de sa chaîne, à l'entrée **et après** — un sous-agent qui accepterait `set_mode: auto` n'aurait pas de plafond du tout, et c'est la moitié qu'on oublie. Corollaire qui évite une règle en plus : sous un plafond `standard`, un sous-agent ne peut faire que des lectures — donc rien qui demande une approbation que personne n'est là pour lui donner. Personne ne regarde un sous-agent ; il n'a donc le droit de rien d'irréversible, sauf si un humain a explicitement mis la chaîne en `auto`. `deleguer(mission, modele)` : workflow enfant, son journal, **son modèle**. C'est ce qui permet une équipe où chacun a le modèle qui lui va, sans que le parent sache comment l'autre est fait. **Un piège du cœur, trouvé en le faisant.** `ChildWorkflowStub::argumentsToInput()` apparie les arguments **par position** ; PHP passe les arguments nommés à `__call` dans un tableau à clés de chaînes, aucun indice n'y répond, et *tous* les paramètres retombent sur leur défaut. Sans exception, sans trace : le sous-agent démarrait avec un prompt vide et attendait un message qui ne viendrait jamais. Diagnostiqué dans l'historique Temporal. Contourné ici par un appel positionnel commenté ; le correctif appartient au cœur et à son propre change. Six tests, dont le témoin sans lequel ils ne prouveraient rien : le mode est lu **à travers la garde** — l'outil externe part-il, ou attend-il un accord que personne ne donnera ? Vérifié en vrai contre Temporal : parent en `standard`, enfant démarré avec `mode: standard`, `modeCeiling: standard`, `model: ministral-3b-latest`, et sa réponse relue par le parent. Hors périmètre, et c'est écrit dans le journal du jour : piloter un pair (régler son mode, approuver ses outils) — c'est la moitié qui casse l'invariant, si B a un humain, l'humain de B garde son veto ; la remontée des approbations d'un descendant vers l'humain de la racine ; et la borne de messages croisés, sans laquelle deux agents qui se parlent sont une boucle infiniment durable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Depuis que les deux facettes ont chacune leur domaine, plus rien ne menait à l'agent : on n'y arrivait qu'en tapant l'URL. Un troisième bouton dans le bandeau, à côté de la documentation et du dashboard. `path()` s'aperçoit tout seul que l'hôte diffère et rend `//agent.durable.localhost/durable/chat`. Symétrique du « retour aux samples » du chat, qui fait le chemin inverse de la même façon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…econstruisaient Trois tableaux associatifs de la démo d'agent portaient du domaine, pas du fil. `ChatCompletion` — la forme « chat completions ». `['choices'][0]['message'][…]` s'écrivait à la main partout où on en avait besoin : le workflow pour la compaction, la projection trois fois, le modèle scripté pour la fabriquer. Fabrique de frontière dans les deux sens, donc `ScriptedChatModelClient` écrit désormais par le type que la projection relit — sans ça les deux dérivaient ensemble, sans témoin, pendant que le vrai fournisseur ne dérivait pas. `SettledCalls` — quatre `array<string, true>` (`$executed`, `$decided`, `$answered`, `$alerted`) que la projection ne consultait que par une condition à quatre `isset()`. Une cinquième façon de régler un appel voulait dire ne pas oublier de l'ajouter aux deux endroits. `Toolset` — la garde voulait une map nom → effet, le toolbox une liste de schémas, le journal un objet indexé par nom ; chacun la reconstruisait. D'où `ToolDefinition::listFromWire()`/`listToWire()` et un `effects()` privé dans la fabrique d'agent, tous trois supprimés. La liste vit dans la collection, les trois formes sont des méthodes. Un changement de comportement, pas seulement de forme : la lecture du résumé de compaction ne gardait `content` que s'il était **déjà une chaîne**. Un modèle qui raisonne le rend en morceaux `thinking`/`text` — le résumé était alors ignoré, et la page affichait le fil entier alors que le modèle ne verrait que le résumé. Tenu par `testAChunkedDigestIsReadLikeAnyOtherAnswer`, qui échoue en affichant le vieux symptôme si on rétablit la lecture brute. Vérification : 139 → 145 tests, même profil de passage, aucun test existant modifié pour accommoder le changement — seules deux constructions de `ModeToolGuard` suivent la signature. Trois mutations pour prouver que les nouveaux tests mordent : `finish_reason` figé, raisonnement écrit en morceau `text`, lecture brute du résumé — les trois sont rattrapées. Laissés en tableaux, parce qu'ils sont du fil : `Turn::$messages`, `ToolDefinition::$parameters`, `descendTo()`, les charges de signaux, et les fixtures de test qui construisent `['choices' => …]` à la main — une fixture qui passe par le type de production ne peut plus détecter un décalage de forme. Pas de VO pour la charge de `continueAsNew` : la signature de `run()` est à la fois un contrat positionnel (`ChildWorkflowStub::argumentsToInput()` apparie par indice) et un contrat par clés (`dispatchWorkflowRun`). Elle ne peut pas se replier sur un objet, et un VO à côté ferait trois listes de paramètres au lieu de deux, pour un seul site d'appel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores documentation/user/use-cases/durable-agent.md as it stood before 761ea76 removed it, and adds what that commit said was missing: a "How to run it" section, mirroring the Nexus page. The section is what was actually done to start the demo on this branch: compose stack, symfony serve on 8012 with the workers from .symfony.local.yaml, the host-constrained URLs, the scripted client as the keyless default. The two stale claims the removal named are corrected: the hand-written converter is gone in favour of the Mistral bridge, and reasoning now travels as thinking parts rather than a reasoning_content the bridge rejects. The use-cases section itself does not exist on this branch yet; the index row is for the merge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same restoration and the same additions as the English page: the run section, the corrected provider and reasoning bullets. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
APP_HOST_SAMPLES and APP_HOST_AGENT are empty by default: an empty host on a route is no constraint, so http://localhost:<port>/ serves the samples and /durable/chat the agent, on any machine. Set both in .env.local to get the per-facet hosts back. The agent's own `/` goes away: it only existed because two facets each wanted a root, and the samples root already links to the chat. Mercure's cors_origins becomes `*`: the page's port is whatever the person launching chose, and the subscription is anonymous and local. The public URL says localhost, like the page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nywhere A "What the demo shows" section lists the features with the phrases that trigger them on the scripted model: guard and modes, questions, watch and alert, delegation, reasoning, context budget, close and resume, Mercure. The run section drops the reverse-proxy hosts for http://localhost:8012 and documents the plain path — compose, two workers, php -S — which was run end to end. `symfony serve` stays as the shortcut, with what it implies: the Docker stack lives and dies with it, and workers that lose Temporal exit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts resolved towards main: the timer fix and its test exist on both sides, main's copy has the English comments; .env.dev takes main's queue names, the _spike suffix was a workaround for one shared dev database. The samples index keeps its third button, to the agent demo, in English like the other two. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gplanchat
added a commit
that referenced
this pull request
Sep 12, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A spike, confined to
symfony/(the sample application): Symfony AI's agent loop run fromworkflow code, so that a conversation is a workflow execution, a message is a signal, and the
agent survives a restart between two tool calls. No package, no bundle, no ADR.
The agent itself is not modified. A regular
Provideris composed with two implementations ofours:
ModelClientInterfacebecomes anawaiton an activity (the only HTTP call in the wholeagent),
ToolExecutorInterfacebecomes anawaitper tool call, preceded by a guard.Runner,which is
@internal, is never touched.symfony/aiis pinned to v0.13.0, the latest release,because 0.x makes no compatibility promise between minors.
What the demo shows
read,write,external); the mode(
standard,edition,auto) decides what passes without asking. Anything else suspends theworkflow until a
tool_decisionsignal, with a deadline.doing;
app:agent:evenementraises such a fact from the CLI.summary of the previous one.
?contexte=Nmakes compaction observable by hand.client is replaced. Without
MISTRAL_API_KEY, a scripted client answers: the demo runs with nokey and no network.
How to run it
documentation/user/use-cases/durable-agent.md(and its French mirror) is back — 761ea76 hadremoved it for lack of a launch path — with a "What the demo shows" section and a launch path
that needs PHP 8.2 with
ext-grpc, Composer and Docker, nothing else:Then http://localhost:8012/durable/chat. This path was run end to end, including a from-scratch
composer install. For it to work on any machine, the last commits make the per-facet hosts(
samples.durable.localhost,agent.durable.localhost) an opt-in through.env.local; empty bydefault, they put no host constraint on the routes. Mercure's CORS is opened to any origin, since
the page's port is whoever launches it's choice.
The use-cases section itself is not on this branch (it is 373 commits behind
main); the tablerow in
_index.md/_index.fr.mdgoes back at merge time, and 761ea76 shows which lines.To be aware of
symfony/composer.jsonandcomposer.lockchange:symfony/ai-agent,symfony/ai-mistral-platform(both v0.13.0),symfony/mercure-bundle,symfony/lock.fix(temporal): do not cancel an already-cancelled timer, withTimerCancelNotRepeatedTest, insrc/Bridge/Temporal. It was found by the demo.git merge-treeagainstmainreports no conflicts.symfony serve, the Docker stack is one of the workers in.symfony.local.yaml: it livesand dies with the server, and a worker that loses Temporal exits rather than retrying.
cross-process crash; the reasoning signature is not carried by any
ai-platformnormalizer.