Wie holt sich ein Claude-Chat osFoundry-Tasks per MCP ohne Push-Events?
>> {"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}"}]}} Das sind ein Claim, ein Submit und ein wiederholter Submit, mitgeschnitten aus einem Python-Nachbau (ein sqlite-Server mit 180 Zeilen) der Übergabe, mit der osFoundry Arbeit an eine verknüpfte Claude- oder ChatGPT-Konversation schickt. Die Tool-Namen stammen vom echten Connector. Der Server, das Lease-Feld und das Replay-Flag gehören zum Nachbau. Es wurde weder ein Claude-Binary noch ein Claude-Account verwendet: Der "Chat" auf der Gegenseite ist ein Python-Skript, das dieselben JSON-RPC-Aufrufe macht wie ein Host, gegen eine lokale sqlite-Datei.
Das Problem hat eine ungewöhnliche Form. Der Worker ist eine Chat-Konversation, die nur läuft, wenn ein Mensch sie öffnet und etwas eintippt. Der Server kann sie nicht aufwecken, nicht sehen und nicht erkennen, ob der Mensch den Tab mitten in einem Task geschlossen hat.
Was stellt osFoundry tatsächlich bereit?
Vier Tools im MCP-Connector von osFoundry tragen die Übergabe, und ihre Schemas verraten das meiste, worauf es ankommt:
| Tool | Pflichtargumente | Was die Beschreibung verspricht |
|---|---|---|
link_current_chat | project_id, label, idempotency_key | Ein eigener Link pro Konversation; Label und Referenz meldet der Chat selbst |
read_linked_chat_tasks | chat_link_id | Wartende und kürzliche Tasks, beschränkt auf die OAuth-Verbindung und das Projekt |
claim_linked_chat_task | chat_link_id, task_id | "Repeating the claim returns the same claim while the task remains active." (ein wiederholter Claim liefert denselben Claim, solange der Task aktiv ist) |
submit_linked_chat_result | chat_link_id, task_id, claim_id, status, result | completed oder failed, Ergebnis bis 20,000 Zeichen; abgebrochene, abgelaufene, widerrufene oder nicht passende Claims lassen sich nicht abschließen |
Die Owner-Seite lebt in der Web-App, in apps/web/src/screens/settings/ConnectedChatTasks.tsx. Ihre Status-Union ist queued | claimed | completed | failed | cancelled | expired, beschriftet mit "Waiting for host chat" (oder "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled" und "Expired". Beim Zuweisen wird das Ziel mit einem idempotency_key aus crypto.randomUUID() gepostet, und die Komponente behält diesen Key, solange sich der Zieltext nicht ändert. Ein Doppelklick oder ein Retry nach einem Timeout schickt also denselben Key. Das Panel aktualisiert seine Liste mit setInterval(..., 15000). Tasks laufen nach 24 Stunden ab.
Ein fünftes Tool, upload_linked_chat_artifact, nimmt beim Retry eine stabile artifact_id. Dieselbe Idee, angewendet auf Dateien.
Warum kann der Server den Task nicht an Claude pushen?
Weil auf der Host-Seite niemand zuhört. osFoundry behandelt einen Custom Connector von Claude als request-getrieben: Die aktive Konversation kann Tools aufrufen, und es gibt keinen allgemeinen Event-Trigger, der einen Turn starten würde. ChatGPT hat MCP Events für Work-Chats, die ein Abo haben; das Owner-Panel zeigt "An event subscription is active for this chat" nur in diesem Fall, sonst "Open the host conversation to start each task." (öffne die Host-Konversation, um jeden Task zu starten).
Der Scheduler ist also ein Mensch. Das verschiebt, welche Fehler normal sind. Ein Task, der sechs Stunden in der Queue liegt, ist kein Ausfall. Ein geclaimter Task, dessen Chat geschlossen wurde, ist der Normalfall, nicht der Randfall. Und ein Retry ist nicht nur eine Netzwerksache: Das Modell ruft das Tool vielleicht einfach zweimal in einem Turn auf.
Wie können zwei Claims auf denselben Task beide gewinnen?
Der naheliegende Handler liest die Zeile, prüft status == 'queued' und schreibt claimed. Der Nachbau hat diese Variante hinter --naive 1 und standardmäßig die bedingte Variante:
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 Tasks, und für jeden rufen acht Threads, die von einer threading.Barrier gleichzeitig losgelassen werden, im selben Moment claim_linked_chat_task über HTTP auf.
| Handler | Durchläufe mit 2+ Gewinnern | Gewinner pro Task im Mittel |
|---|---|---|
| Lesen, dann schreiben (2 ms dazwischen) | 200 / 200 | 7.40 |
| Lesen, dann schreiben (ohne zusätzliche Lücke) | 97 / 200 und 172 / 200 in zwei Läufen | 1.82 und 3.36 |
Bedingtes UPDATE … WHERE status='queued' | 0 / 200 | 1.00 |
Die 2 ms Lücke stehen für einen Netzwerk-Hop oder ein ORM zwischen den beiden Statements. Ohne sie tritt das Race trotzdem in etwa der Hälfte der Durchläufe oder öfter auf, und die Zahl schwankte mit der Last der Maschine. Der Write-Lock von SQLite serialisiert die beiden Schreibvorgänge, hilft aber nicht: Beide Leser haben ihre Entscheidung schon getroffen.
Acht gleichzeitige Claims aus einem verknüpften Chat klingen konstruiert. Zwei nicht: Der Nutzer bittet den Chat, nach Tasks zu schauen, wird ungeduldig, fragt noch einmal, und der Host führt beide Turns aus. Genau deshalb gibt der Claim von osFoundry demselben Link den bestehenden Claim zurück statt eines Fehlers. Der Nachbau macht es bei einer Wiederholung aus demselben Chat genauso:
"claim_replay": {"first": "28a03379", "repeat_same_chat": "28a03379", "same_claim": true, "other_link": {"error": "not claimable: missing"}} Ein anderer Link, der dieselbe Task-ID anfragt, bekommt nichts, nicht einmal die Bestätigung, dass der Task existiert.
Was sollte passieren, wenn der Chat, der den Task geclaimt hat, verschwindet?
Hier geht der Nachbau weiter als alles, was die osFoundry-Doku für verknüpfte Chats festlegt. Sie nennt einen Task-Ablauf nach 24 Stunden und, für den Desktop-Dispatcher, mit dem ein osFoundry-Agent auf einen verknüpften Chat warten kann, ein Laufzeitlimit von 30 Minuten. Der Nachbau ergänzt ein Lease pro Claim: Ein Claim, der bis lease_until nicht abgeschlossen ist, geht zurück auf queued und kann erneut geclaimt werden.
Der Ablauf ist lazy. Es gibt keinen Timer-Thread; jeder Read, Claim und Submit führt vorher einen Sweep aus:
c.execute("UPDATE tasks SET status='queued', claim_id=NULL, lease_until=NULL, updated_at=? " "WHERE status='claimed' AND lease_until<=?", (now, now)) Test mit einem Lease von 1 Sekunde: Chat A claimt und verstummt. Chat B versucht alle 100 ms zu claimen.
"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 hat 64 ms nach Ablauf des Leases übernommen, also ein Poll-Intervall plus Request-Zeit. Wichtiger ist die dritte Zeile: Als A zurückkam und mit seiner alten claim_id einen Submit schickte, hat der Server abgelehnt. Die Claim-ID ist ein Fencing-Token. Ohne sie in der WHERE-Klausel würde die verspätete Antwort von A die von B überschreiben, und der Owner sähe die, die zuletzt fertig wurde.
Die Lease-Dauer zu wählen ist bei einem menschengetriebenen Worker der schwierige Teil. Zu kurz, und ein langsamer, aber lebendiger Claude-Turn verliert seinen Task mitten in der Antwort. Zu lang, und ein geschlossener Tab blockiert Arbeit über Stunden. Ein Lease lohnt sich nur, wenn jemand anderes den Task übernehmen kann; bei genau einem verknüpften Chat pro Task sind der 24-Stunden-Ablauf plus ein "Cancel"-Button für den Owner vielleicht das ehrliche Design.
Was passiert, wenn die Antwort auf einen Submit verloren geht?
Der Host schickt das Ergebnis, der Server committet, die Antwort geht auf dem Rückweg verloren, der Host versucht es erneut. Der Test schickt denselben Submit viermal und dann einen fünften mit einem anderen Ergebnis:
| Handler | Antworten auf 4 identische Sends | Antwort auf ein geändertes Ergebnis | Gespeichertes Ergebnis |
|---|---|---|---|
| Hängt jeden Aufruf an (wie das Posten einer Nachricht) | ok, ok, ok, ok | ok | 4\n4\n4\n4\n5 |
| Claim-gefenct, Ergebnis-Hash verglichen | ok, dann 3 × ok + replayed | rejected: task is completed | 4 |
Die idempotente Variante speichert sha256(status + result) in der Zeile. Ein Retry mit demselben Claim und demselben Hash bekommt Erfolg; alles andere gegen einen abgeschlossenen Task wird abgelehnt. osFoundry wendet dieselbe Regel auf Completions in seinem Graph-Relay an: Eine identische Completion nach einer verlorenen Antwort wird erneut akzeptiert, eine geänderte abgelehnt. Der Retry muss Erfolg zurückgeben, keinen Fehler. Ein Fehler würde das Modell dazu bringen, ein Ergebnis zu "reparieren", das längst gespeichert war.
Lässt sich ein abgebrochener Task noch abschließen?
Nicht, wenn Cancel ein Statuswechsel ist und der Submit status='claimed' voraussetzt:
"cancel": {"cancel": {"cancelled": true}, "submit_after_cancel": {"error": "rejected: task is cancelled"}} Interessant ist, was der Chat sieht. Der Claude-Turn hat die Arbeit erledigt; die Antwort prallt ab. Die Tool-Beschreibung von osFoundry sagt dem Modell vorab, dass abgebrochene Claims nicht abgeschlossen werden können. Das ist der billigste Weg, um zu verhindern, dass es in einer Schleife wiederholt.
Was kostet das Polling?
Es gibt zwei verschiedene Poller. Das Browser-Panel des Owners pollt alle 15 Sekunden, also 5,760 Requests pro Tag und offenem Panel. Der Host-Chat pollt gar nicht; er liest einmal, jedes Mal wenn du ihn darum bittest. Beide treffen dieselbe Read-Query.
Gemessen am Nachbau mit 10,000 historischen Tasks über 500 Links, 2,000 Reads eines Links, HTTP auf localhost:
| Request | Response | Round Trip p50 | Server p50 | |
|---|---|---|---|---|
| Leere Inbox | 137 B | 87 B | 0.72 ms | 0.26 ms |
| Ein wartender Task | 137 B | 294 B | 0.89 ms | 0.30 ms |
Die Bandbreite ist nicht der Kostenfaktor. Die erste Version war auf dem Server etwa neunmal langsamer, und der Grund war der lazy Sweep: zwei UPDATEs bei jedem Read, die den Write-Lock von SQLite nehmen, auch wenn sie nichts treffen. Drei Läufe mit je 2,000 leeren Polls:
| Sweep vor jedem Read | Server p50 (3 Läufe) |
|---|---|
Zwei UPDATEs, Table Scan | 2.17 / 2.02 / 1.96 ms |
Zwei UPDATEs, partieller Index auf aktive Zeilen | 1.33 / 1.33 / 1.20 ms |
Erst SELECT 1 … LIMIT 1 auf den partiellen Index, UPDATE nur wenn etwas fällig ist | 0.24 / 0.21 / 0.22 ms |
| Kein Sweep | 0.19 / 0.19 / 0.22 ms |
Ein reiner Read-Guard bringt einen Poll bis auf Rauschen an den Fall ohne Sweep heran. Der Index allein schafft das nicht, weil die Schreibtransaktion der Kostenfaktor ist, nicht der Scan.
Für einen geskripteten Client, der tatsächlich pollt, ist der Trade-off der übliche. 40 Tasks, die im Abstand von 0.2 bis 1.2 s eintreffen:
| Poll-Intervall | Mittlere Abholverzögerung | Max | Polls | Leere Polls |
|---|---|---|---|---|
| 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 |
Die mittlere Abholverzögerung liegt bei etwa dem halben Intervall. Für einen Host-Chat ist das echte Intervall "wann immer der Nutzer ihn öffnet", und die Kosten pro Read, die zählen, sind Kontext, nicht Millisekunden: Jedes Read-Ergebnis landet in der Konversation. Bei 87 Bytes für eine leere Inbox und etwa 210 mehr pro wartendem Task in diesem Format hält eine knappe Task-Liste den Check so billig, dass man ihn ohne Bedenken anstoßen kann.
Alle Zahlen: Apple M1, Python 3.9.6, SQLite 3.43.2 im WAL-Modus, ThreadingHTTPServer auf 127.0.0.1, während andere Render-Jobs auf der Maschine liefen (Load Average zwischen 8 und 22 während der Läufe). Verlass dich also auf die p50-Werte und betrachte die Tails als verrauscht.
Wenn du das selbst verdrahtest
- Mach den Claim zu einem einzigen bedingten Write und gib demselben Aufrufer den bestehenden Claim zurück; Doppelklick und doppelter Tool-Aufruf sind der Normalfall.
- Setz die Claim-ID in die
WHERE-Klausel des Submits, damit ein verspäteter Worker keinen neueren überschreiben kann. - Speichere einen Hash des finalen Ergebnisses und beantworte einen identischen Retry mit Erfolg, einen geänderten mit einer Ablehnung.
- Entscheide, ob ein Lease überhaupt etwas bringt. Wenn nur ein Chat den Task je übernehmen kann, reichen vielleicht ein Ablauf und ein sichtbares Cancel.
- Halte Ablaufprüfungen read-only, bis wirklich etwas fällig ist; ein Poll, der einen Write-Lock nimmt, kostet mehr als die Query, die er absichert.
- Schreib in die Tool-Beschreibung, was abgelehnt wird, damit das Modell nicht gegen einen abgebrochenen Task wiederholt.