DEV Community

Cover image for Comment un chat Claude réclame-t-il des tâches osFoundry via MCP sans événements push ?
BBT for BBT Français

Posted on

Comment un chat Claude réclame-t-il des tâches osFoundry via MCP sans événements push ?

>> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "claim_linked_chat_task",
    "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-3916-4b13-937d-d78de6b3b053"}}}
<< {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text",
    "text": "{\"claim_id\": \"8ffc774f-0a76-4846-9bbd-d66493ca08f4\", \"lease_until\": 1793942663.8222258}"}]}}

>> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "submit_linked_chat_result",
    "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-…", "claim_id": "8ffc774f-…",
                  "status": "completed", "result": "1. Who signs off the pricing copy?"}}}
<< {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true}"}]}}

>> (same submit again, after a "lost" response)
<< {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true, \"replayed\": true}"}]}}
Enter fullscreen mode Exit fullscreen mode

Voici un claim, un submit et un submit rejoué, capturés sur une reconstruction en Python (un serveur sqlite de 180 lignes) du transfert qu'utilise osFoundry pour envoyer du travail à une conversation Claude ou ChatGPT liée. Les noms d'outils sont ceux du vrai connecteur. Le serveur, le champ de lease et le flag de replay viennent de la reconstruction. Aucun binaire ni compte Claude n'a été utilisé : le « chat » de l'autre côté est un script Python qui fait les mêmes appels JSON-RPC qu'un hôte, sur un fichier sqlite local.

Le problème a une forme inhabituelle. Le worker est une conversation de chat qui ne tourne que lorsqu'un humain l'ouvre et tape quelque chose. Le serveur ne peut pas la réveiller, ne peut pas la voir et ne peut pas savoir si l'humain a fermé l'onglet au milieu d'une tâche.

Qu'est-ce qu'osFoundry expose réellement ?

Quatre outils du connecteur MCP d'osFoundry portent le transfert, et leurs schémas disent l'essentiel de ce qui compte :

Outil Arguments obligatoires Ce que promet sa description
link_current_chat project_id, label, idempotency_key Un lien distinct par conversation ; le libellé et la référence sont déclarés par le chat lui-même
read_linked_chat_tasks chat_link_id Tâches en file et récentes, limitées à la connexion OAuth et au projet
claim_linked_chat_task chat_link_id, task_id "Repeating the claim returns the same claim while the task remains active." (répéter le claim renvoie le même claim tant que la tâche reste active)
submit_linked_chat_result chat_link_id, task_id, claim_id, status, result completed ou failed, résultat jusqu'à 20,000 caractères ; les claims annulés, expirés, révoqués ou qui ne correspondent pas ne peuvent pas être complétés

Le côté propriétaire vit dans l'application web, dans apps/web/src/screens/settings/ConnectedChatTasks.tsx. Son union de statuts est queued | claimed | completed | failed | cancelled | expired, avec les libellés "Waiting for host chat" (ou "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled" et "Expired". L'assignation poste l'objectif avec un idempotency_key issu de crypto.randomUUID(), et le composant conserve cette clé tant que le texte de l'objectif ne change pas : un double-clic ou un retry après un timeout envoie donc la même clé. Le panneau rafraîchit sa liste avec setInterval(..., 15000). Les tâches expirent au bout de 24 heures.

Un cinquième outil, upload_linked_chat_artifact, prend un artifact_id stable lors d'un retry. Même idée, appliquée aux fichiers.

Pourquoi le serveur ne peut-il pas pousser la tâche vers Claude ?

Parce que rien n'écoute côté hôte. osFoundry traite un connecteur personnalisé Claude comme piloté par les requêtes : la conversation active peut appeler des outils, et il n'existe pas de déclencheur d'événements général qui lancerait un tour. ChatGPT dispose de MCP Events pour les chats Work qui s'abonnent ; le panneau propriétaire affiche "An event subscription is active for this chat" uniquement dans ce cas, et sinon "Open the host conversation to start each task." (ouvrez la conversation hôte pour lancer chaque tâche).

Le scheduler est donc une personne. Cela change les pannes qui sont normales. Une tâche en file depuis six heures n'est pas une panne. Une tâche réclamée dont le chat a été fermé est le cas courant, pas le cas limite. Et un retry n'est pas qu'une affaire de réseau : le modèle peut tout simplement appeler l'outil deux fois dans le même tour.

Comment deux claims sur la même tâche peuvent-ils gagner tous les deux ?

Le handler évident lit la ligne, vérifie status == 'queued' et écrit claimed. La reconstruction propose cette version derrière --naive 1, et la version conditionnelle par défaut :

cur = c.execute("UPDATE tasks SET status='claimed', claim_id=?, lease_until=?, updated_at=? "
                "WHERE id=? AND chat_link_id=? AND status='queued'",
                (cid, now + self.lease, now, task_id, link))
if cur.rowcount == 1:
    return {"claim_id": cid, "lease_until": now + self.lease}
Enter fullscreen mode Exit fullscreen mode

Test : 200 tâches, et pour chacune, huit threads libérés par un threading.Barrier appellent claim_linked_chat_task au même instant via HTTP.

Handler Essais avec 2+ gagnants Gagnants moyens par tâche
Lire, puis écrire (2 ms entre les deux) 200 / 200 7.40
Lire, puis écrire (sans délai ajouté) 97 / 200 et 172 / 200 sur deux exécutions 1.82 et 3.36
UPDATE … WHERE status='queued' conditionnel 0 / 200 1.00

Le délai de 2 ms simule un saut réseau ou un ORM entre les deux requêtes. Sans lui, la race condition se produit quand même sur environ la moitié des essais ou plus, et le chiffre variait avec la charge de la machine. Le verrou d'écriture de SQLite sérialise les deux écritures mais n'aide pas : les deux lecteurs ont déjà décidé.

Huit claims simultanés depuis un même chat lié, ça paraît artificiel. Deux, non : l'utilisateur demande au chat de vérifier les tâches, s'impatiente, redemande, et l'hôte exécute les deux tours. C'est aussi pour cela que le claim d'osFoundry renvoie le claim existant au même lien au lieu d'une erreur. La reconstruction fait de même pour une répétition venant du même chat :

"claim_replay": {"first": "28a03379", "repeat_same_chat": "28a03379", "same_claim": true,
                 "other_link": {"error": "not claimable: missing"}}
Enter fullscreen mode Exit fullscreen mode

Un autre lien qui demande le même ID de tâche n'obtient rien, pas même la confirmation que la tâche existe.

Que doit-il se passer quand le chat qui a réclamé la tâche disparaît ?

C'est ici que la reconstruction va plus loin que tout ce que la documentation d'osFoundry indique pour les chats liés. Elle mentionne une expiration des tâches à 24 heures et, pour le dispatcher desktop qui permet à un agent osFoundry d'attendre un chat lié, une limite d'exécution de 30 minutes. La reconstruction ajoute un lease par claim : un claim qui n'est pas complété avant lease_until repasse en queued et peut être réclamé à nouveau.

L'expiration est paresseuse. Il n'y a pas de thread de minuterie ; chaque lecture, claim et submit lance d'abord un balayage :

c.execute("UPDATE tasks SET status='queued', claim_id=NULL, lease_until=NULL, updated_at=? "
          "WHERE status='claimed' AND lease_until<=?", (now, now))
Enter fullscreen mode Exit fullscreen mode

Test avec un lease d'une seconde : le chat A réclame puis se tait. Le chat B tente de réclamer toutes les 100 ms.

"lease": {"lease_s": 1.0, "takeover_after_s": 1.064, "failed_claims_before_takeover": 10,
          "late_submit_A": {"error": "rejected: stale claim, task was re-claimed"},
          "submit_B": {"ok": true}}
Enter fullscreen mode Exit fullscreen mode

B a pris la main 64 ms après l'expiration du lease, soit un intervalle de polling plus le temps de requête. La ligne la plus importante est la troisième : quand A est revenu et a soumis avec son ancien claim_id, le serveur a refusé. L'ID de claim est un fencing token. Sans lui dans la clause WHERE, la réponse tardive de A écraserait celle de B, et le propriétaire verrait celle qui a fini en dernier.

Choisir la durée du lease est la partie difficile avec un worker piloté par un humain. Trop court, et un tour Claude lent mais bien vivant perd sa tâche en pleine réponse. Trop long, et un onglet fermé bloque du travail pendant des heures. Un lease n'a d'intérêt que si quelqu'un d'autre peut reprendre la tâche ; avec exactement un chat lié par tâche, l'expiration à 24 heures plus un bouton "Cancel" côté propriétaire est peut-être le design honnête.

Que se passe-t-il quand la réponse d'un submit se perd ?

L'hôte envoie le résultat, le serveur fait le commit, la réponse se perd au retour, l'hôte réessaie. Le test envoie le même submit quatre fois, puis un cinquième avec un résultat différent :

Handler Réponses aux 4 envois identiques Réponse à un résultat modifié Résultat stocké
Ajoute chaque appel (comme poster un message) ok, ok, ok, ok ok 4\n4\n4\n4\n5
Protégé par le claim, hash du résultat comparé ok, puis 3 × ok + replayed rejected: task is completed 4

La version idempotente garde sha256(status + result) sur la ligne. Un retry avec le même claim et le même hash reçoit un succès ; tout le reste sur une tâche terminée est refusé. osFoundry applique la même règle aux complétions sur son relais Graph : une complétion identique après une réponse perdue est de nouveau acceptée, une complétion modifiée est refusée. Le retry doit renvoyer un succès, pas une erreur. Une erreur pousserait le modèle à « corriger » un résultat déjà enregistré.

Une tâche annulée peut-elle encore être complétée ?

Non, si l'annulation est un changement d'état et que le submit exige status='claimed' :

"cancel": {"cancel": {"cancelled": true}, "submit_after_cancel": {"error": "rejected: task is cancelled"}}
Enter fullscreen mode Exit fullscreen mode

Le plus intéressant, c'est ce que voit le chat. Le tour Claude a fait le travail ; la réponse a rebondi. La description d'outil d'osFoundry prévient le modèle à l'avance que les claims annulés ne peuvent pas être complétés, ce qui est le moyen le moins coûteux de l'empêcher de réessayer en boucle.

Combien coûte le polling ?

Il existe deux pollers distincts. Le panneau propriétaire dans le navigateur interroge toutes les 15 secondes, soit 5,760 requêtes par jour et par panneau ouvert. Le chat hôte ne fait aucun polling ; il lit une fois, chaque fois que vous le lui demandez. Les deux passent par la même requête de lecture.

Mesuré sur la reconstruction avec 10,000 tâches historiques réparties sur 500 liens, 2,000 lectures d'un lien, HTTP en localhost :

Requête Réponse Aller-retour p50 Serveur p50
Boîte vide 137 B 87 B 0.72 ms 0.26 ms
Une tâche en file 137 B 294 B 0.89 ms 0.30 ms

Le coût n'est pas la bande passante. La première version était environ neuf fois plus lente côté serveur, et la cause était le balayage paresseux : deux UPDATE à chaque lecture, qui prennent le verrou d'écriture de SQLite même quand ils ne touchent aucune ligne. Trois exécutions de 2,000 polls vides chacune :

Balayage avant chaque lecture Serveur p50 (3 exécutions)
Deux UPDATE, scan de table 2.17 / 2.02 / 1.96 ms
Deux UPDATE, index partiel sur les lignes actives 1.33 / 1.33 / 1.20 ms
D'abord SELECT 1 … LIMIT 1 sur l'index partiel, UPDATE seulement si quelque chose est échu 0.24 / 0.21 / 0.22 ms
Pas de balayage 0.19 / 0.19 / 0.22 ms

Une garde en lecture seule ramène un poll dans le bruit par rapport à l'absence totale de balayage. L'index seul n'y parvient pas, parce que le coût vient de la transaction d'écriture, pas du scan.

Pour un client scripté qui fait réellement du polling, le compromis est le compromis habituel. 40 tâches arrivant à 0.2 à 1.2 s d'intervalle :

Intervalle de polling Délai moyen de prise en charge Max Polls Polls vides
0.1 s 0.058 s 0.107 s 244 204
0.5 s 0.226 s 0.484 s 54 17
2.0 s 1.103 s 2.006 s 15 2

Le délai moyen de prise en charge tombe autour de la moitié de l'intervalle. Pour un chat hôte, l'intervalle réel est « quand l'utilisateur l'ouvre », et le coût par lecture qui compte, c'est le contexte, pas les millisecondes : chaque résultat de lecture entre dans la conversation. Avec 87 octets pour une boîte vide et environ 210 de plus par tâche en file dans ce format, une liste de tâches concise garde la vérification assez bon marché pour la demander sans y penser.

Tous les chiffres : Apple M1, Python 3.9.6, SQLite 3.43.2 en mode WAL, ThreadingHTTPServer sur 127.0.0.1, avec d'autres jobs de rendu en cours sur la machine (load average entre 8 et 22 pendant les exécutions). Fiez-vous donc aux p50 et considérez les queues de distribution comme bruitées.

Si vous câblez cela vous-même

  • Faites du claim une seule écriture conditionnelle et renvoyez le claim existant au même appelant ; le double-clic et le double appel d'outil sont le cas normal.
  • Mettez l'ID de claim dans la clause WHERE du submit pour qu'un worker en retard ne puisse pas écraser un plus récent.
  • Stockez un hash du résultat final et répondez à un retry identique par un succès, à un retry modifié par un refus.
  • Demandez-vous si un lease apporte quelque chose. Si un seul chat peut jamais prendre la tâche, une expiration et une annulation visible suffisent peut-être.
  • Gardez les vérifications d'expiration en lecture seule tant que rien n'est réellement échu ; un poll qui prend un verrou d'écriture coûte plus cher que la requête qu'il protège.
  • Indiquez dans la description de l'outil ce qui sera refusé, pour que le modèle ne réessaie pas sur une tâche annulée.

Top comments (0)