Come fa una chat Claude a prendere in carico i task di osFoundry via MCP senza eventi 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": "
>> {"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}"}]}}
Qui sopra ci sono un claim, un submit e un submit ripetuto, catturati da una ricostruzione in Python (un server sqlite di 180 righe) del passaggio di consegne che osFoundry usa per mandare lavoro a una conversazione Claude o ChatGPT collegata. I nomi dei tool sono quelli del connettore reale. Il server, il campo lease e il flag di replay sono della ricostruzione. Non sono stati usati né un binario di Claude né un account Claude: la "chat" dall'altra parte è uno script Python che fa le stesse chiamate JSON-RPC che farebbe un host, contro un file sqlite locale.
Il problema ha una forma insolita. Il worker è una conversazione in chat che gira solo quando una persona la apre e scrive qualcosa. Il server non può svegliarla, non può vederla e non può sapere se la persona ha chiuso la scheda a metà di un task.
Cosa espone davvero osFoundry?
Il passaggio di consegne passa per quattro tool del connettore MCP di osFoundry, e i loro schemi dicono già quasi tutto quello che conta:
| Tool | Argomenti obbligatori | Cosa promette la descrizione |
|---|---|---|
link_current_chat |
project_id, label, idempotency_key
|
Un link separato per ogni conversazione; label e riferimento sono dichiarati dalla chat stessa |
read_linked_chat_tasks |
chat_link_id |
Task in coda e recenti, limitati alla connessione OAuth e al progetto |
claim_linked_chat_task |
chat_link_id, task_id
|
"Repeating the claim returns the same claim while the task remains active." |
submit_linked_chat_result |
chat_link_id, task_id, claim_id, status, result
|
completed o failed, risultato fino a 20,000 caratteri; claim annullati, scaduti, revocati o non corrispondenti non possono essere completati |
Il lato del proprietario sta nella web app, in apps/web/src/screens/settings/ConnectedChatTasks.tsx. La sua union di stati è queued | claimed | completed | failed | cancelled | expired, con le etichette "Waiting for host chat" (oppure "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled" e "Expired". Quando assegni un task, il goal viene inviato con una idempotency_key generata da crypto.randomUUID(), e il componente conserva quella chiave finché il testo del goal non cambia: un doppio clic o un nuovo tentativo dopo un timeout mandano quindi la stessa chiave. Il pannello aggiorna la lista con setInterval(..., 15000). I task scadono dopo 24 ore.
Un quinto tool, upload_linked_chat_artifact, accetta un artifact_id stabile nei retry. Stessa idea, applicata ai file.
Perché il server non può fare push del task verso Claude?
Perché dal lato host non c'è niente in ascolto. osFoundry tratta un connettore custom di Claude come guidato dalle richieste: la conversazione attiva può chiamare i tool, e non esiste un trigger di eventi generico che faccia partire un turno. ChatGPT ha gli MCP Events per le chat Work che si iscrivono; il pannello del proprietario mostra "An event subscription is active for this chat" solo in quel caso, e altrimenti "Open the host conversation to start each task."
Quindi lo scheduler è una persona. Questo cambia quali guasti sono normali. Un task in coda fermo da sei ore non è un'interruzione del servizio. Un task preso in carico la cui chat è stata chiusa è il caso comune, non il caso limite. E un retry non è solo una questione di rete: il modello può semplicemente chiamare il tool due volte nello stesso turno.
Come fanno due claim sullo stesso task a vincere entrambi?
L'handler ovvio legge la riga, controlla status == 'queued' e scrive claimed. La ricostruzione ha questa versione dietro --naive 1, e di default quella condizionale:
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}
Test: 200 task, e per ognuno otto thread rilasciati da un threading.Barrier chiamano claim_linked_chat_task nello stesso istante via HTTP.
| Handler | Prove con 2+ vincitori | Media vincitori per task |
|---|---|---|
| Lettura, poi scrittura (2 ms in mezzo) | 200 / 200 | 7.40 |
| Lettura, poi scrittura (nessun intervallo aggiunto) | 97 / 200 e 172 / 200 in due run | 1.82 e 3.36 |
UPDATE … WHERE status='queued' condizionale |
0 / 200 | 1.00 |
L'intervallo di 2 ms simula un hop di rete o un ORM tra i due statement. Senza, la race scatta comunque in circa metà delle prove o più, e il numero variava con il carico della macchina. Il lock di scrittura di SQLite serializza le due scritture ma non serve: entrambi i lettori hanno già deciso.
Otto claim simultanei da una sola chat collegata sembrano un caso artificiale. Due no: l'utente chiede alla chat di controllare i task, si spazientisce, lo chiede di nuovo, e l'host esegue entrambi i turni. È anche per questo che il claim di osFoundry restituisce il claim esistente allo stesso link invece di un errore. La ricostruzione fa lo stesso per una ripetizione dalla stessa chat:
"claim_replay": {"first": "28a03379", "repeat_same_chat": "28a03379", "same_claim": true,
"other_link": {"error": "not claimable: missing"}}
Un link diverso che chiede lo stesso task ID non ottiene niente, nemmeno la conferma che il task esiste.
Cosa deve succedere quando la chat che ha preso il task sparisce?
Qui la ricostruzione va oltre quello che la documentazione di osFoundry dice sulle chat collegate. La documentazione indica una scadenza dei task di 24 ore e, per il dispatcher desktop che permette a un agente osFoundry di aspettare una chat collegata, un limite di esecuzione di 30 minuti. La ricostruzione aggiunge un lease per ogni claim: un claim non completato entro lease_until torna a queued e può essere preso di nuovo.
La scadenza è lazy. Non c'è un thread con un timer; ogni read, claim e submit esegue prima uno sweep:
c.execute("UPDATE tasks SET status='queued', claim_id=NULL, lease_until=NULL, updated_at=? "
"WHERE status='claimed' AND lease_until<=?", (now, now))
Test con un lease di 1 secondo: la chat A fa il claim e poi tace. La chat B prova a fare il claim ogni 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}}
B ha preso il task 64 ms dopo la scadenza del lease, cioè un intervallo di polling più il tempo della richiesta. La riga più importante è la terza: quando A è tornata e ha fatto submit con il suo vecchio claim_id, il server l'ha rifiutato. Il claim ID è un fencing token. Senza di lui nella clausola WHERE, la risposta in ritardo di A sovrascriverebbe quella di B, e il proprietario vedrebbe quella finita per ultima.
Scegliere la durata del lease è la parte difficile quando il worker è guidato da una persona. Troppo corto, e un turno di Claude lento ma vivo perde il task a metà risposta. Troppo lungo, e una scheda chiusa tiene bloccato il lavoro per ore. Un lease ha senso solo se qualcun altro può riprendere il task; con esattamente una chat collegata per task, la scadenza di 24 ore più un pulsante "Cancel" per il proprietario può essere il design onesto.
Cosa succede quando la risposta a un submit va persa?
L'host invia il risultato, il server fa commit, la risposta muore sulla via del ritorno, l'host riprova. Il test invia lo stesso submit quattro volte, poi una quinta con un risultato diverso:
| Handler | Risposte a 4 invii identici | Risposta a un risultato cambiato | Risultato salvato |
|---|---|---|---|
| Accoda ogni chiamata (come pubblicare un messaggio) | ok, ok, ok, ok | ok | 4\n4\n4\n4\n5 |
| Protetto dal claim, con confronto dell'hash del risultato | ok, poi 3 × ok + replayed
|
rejected: task is completed |
4 |
La versione idempotente tiene sha256(status + result) sulla riga. Un retry con lo stesso claim e lo stesso hash riceve un successo; qualsiasi altra cosa su un task in stato terminale viene rifiutata. osFoundry applica la stessa regola ai completamenti sul suo relay Graph: un completamento identico dopo una risposta persa viene accettato di nuovo, uno cambiato viene rifiutato. Il retry deve restituire successo, non un errore. Un errore spingerebbe il modello a "correggere" un risultato già salvato.
Un task annullato può ancora essere completato?
No, se l'annullamento è un cambio di stato e il submit richiede status='claimed':
"cancel": {"cancel": {"cancelled": true}, "submit_after_cancel": {"error": "rejected: task is cancelled"}}
La parte interessante è cosa vede la chat. Il turno di Claude ha fatto il lavoro; la risposta è stata respinta. La descrizione del tool di osFoundry dice al modello in anticipo che i claim annullati non possono essere completati, ed è il modo più economico per evitare che riprovi in loop.
Quanto costa il polling?
Esistono due poller diversi. Il pannello del proprietario nel browser fa polling ogni 15 secondi, cioè 5,760 richieste al giorno per ogni pannello aperto. La chat host invece non fa polling: legge una volta ogni volta che glielo chiedi. Entrambi usano la stessa query di lettura.
Misurato sulla ricostruzione con 10,000 task storici distribuiti su 500 link, 2,000 letture di un singolo link, HTTP su localhost:
| Richiesta | Risposta | Round trip p50 | Server p50 | |
|---|---|---|---|---|
| Inbox vuota | 137 B | 87 B | 0.72 ms | 0.26 ms |
| Un task in coda | 137 B | 294 B | 0.89 ms | 0.30 ms |
Il costo non è la banda. La prima versione era circa nove volte più lenta lato server, e il motivo era lo sweep lazy: due UPDATE a ogni lettura, che prendono il lock di scrittura di SQLite anche quando non trovano niente. Tre run da 2,000 poll vuoti ciascuno:
| Sweep prima di ogni lettura | Server p50 (3 run) |
|---|---|
Due UPDATE, scansione della tabella |
2.17 / 2.02 / 1.96 ms |
Due UPDATE, indice parziale sulle righe attive |
1.33 / 1.33 / 1.20 ms |
Prima SELECT 1 … LIMIT 1 sull'indice parziale, UPDATE solo se qualcosa è scaduto |
0.24 / 0.21 / 0.22 ms |
| Nessuno sweep | 0.19 / 0.19 / 0.22 ms |
Una guardia in sola lettura porta un poll entro il rumore rispetto a nessuno sweep. L'indice da solo non basta, perché il costo è la transazione di scrittura, non la scansione.
Per un client scriptato che fa davvero polling, il compromesso è quello solito. 40 task che arrivano a distanza di 0.2–1.2 s:
| Intervallo di polling | Ritardo medio di presa in carico | Max | Poll | Poll vuoti |
|---|---|---|---|---|
| 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 |
Il ritardo medio di presa in carico è vicino a metà dell'intervallo. Per una chat host l'intervallo reale è "quando l'utente la apre", e il costo per lettura che conta è il contesto, non i millisecondi: ogni risultato di lettura finisce nella conversazione. Con 87 byte per una inbox vuota e circa 210 in più per ogni task in coda in questo formato, una lista di task compatta tiene il controllo abbastanza economico da poterlo chiedere senza pensarci.
Tutti i numeri: Apple M1, Python 3.9.6, SQLite 3.43.2 in modalità WAL, ThreadingHTTPServer su 127.0.0.1, con altri job di rendering in esecuzione sulla macchina (load average tra 8 e 22 durante i run), quindi leggi i p50 e considera le code rumorose.
Se vuoi collegarlo tu
- Fai del claim una singola scrittura condizionale e restituisci il claim esistente allo stesso chiamante; il doppio clic e la doppia chiamata al tool sono il caso normale.
- Metti il claim ID nella clausola
WHEREdel submit, così un worker in ritardo non può sovrascriverne uno più recente. - Salva un hash del risultato terminale e rispondi a un retry identico con un successo, a uno cambiato con un rifiuto.
- Decidi se un lease ti serve davvero. Se solo una chat può prendere il task, una scadenza e un annullamento visibile possono bastare.
- Tieni i controlli di scadenza in sola lettura finché qualcosa non è davvero scaduto; un poll che prende un lock di scrittura costa più della query che protegge.
- Scrivi nella descrizione del tool cosa verrà rifiutato, così il modello non riprova contro un task annullato.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.