DEV Community

Cover image for Warum lässt osFoundry Codex CLI pro Ressource einen Cursor pollen, statt Änderungen zu pushen?
BBT for BBT Deutsch

Posted on

Warum lässt osFoundry Codex CLI pro Ressource einen Cursor pollen, statt Änderungen zu pushen?

Das hier ist alles, was der MCP-Server von osFoundry einem Client anbietet, der mitbekommen will, wenn sich etwas ändert. Ausgeführt auf diesem Mac, ohne Token:

$ uv run --with tiktoken --with jsonschema python3 watch_sim.py
== Part 1: live wire, 8 calls each, ms ==
initialize                   HTTP 200  median  414.7  p90  757.8
tools/list                   HTTP 200  median  606.8  p90  716.7
resources/subscribe          HTTP 200  median  450.6  p90  550.2
GET (stream)                 HTTP 405  median  423.9  p90  475.4
tools/call poll (no token)   HTTP 401  median  322.0  p90  463.9
initialize capabilities: {"tools": {"listChanged": false}}
resources/subscribe -> {"code": -32601, "message": "Method not found"}
GET body -> ''
poll without token -> isError True | Authentication required
rate limit headers: limit 2000, reset in 61 s
Enter fullscreen mode Exit fullscreen mode

Alle drei Push-Wege sind zu. initialize deklariert nur Tools und sonst nichts, mit listChanged: false, also meldet nicht einmal die Tool-Liste eine Änderung. resources/subscribe liefert "Method not found". Ein GET auf die MCP-URL, über den ein Streamable-HTTP-Server einen Stream vom Server zum Client öffnen würde, gibt 405 zurück. Übrig bleibt ein einziges Read-only-Tool:

{"name": "poll_resource_events",
 "description": "Resume from an event sequence for one granted channel, Note, knowledge base, record, or session.",
 "inputSchema": {"properties": {
     "resource_type": {"enum": ["message_channel", "note", "knowledge_base", "record_channel", "room",
                                "gitnest_repository", "agent_session", "team_session", "loop_session",
                                "graph_session"]},
     "resource_id": {"type": "string"},
     "after_sequence": {"type": "integer", "minimum": 0},
     "limit": {"type": "integer", "minimum": 1, "maximum": 100}},
   "required": ["resource_type", "resource_id"], "additionalProperties": false},
 "annotations": {"readOnlyHint": true, "openWorldHint": false, "destructiveHint": false},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:read"]}]}
Enter fullscreen mode Exit fullscreen mode

Eine "one granted" Ressource, "resume from an event sequence". Um genau diese Worte geht es im Rest des Posts, mit einem in Python nachgebauten Client. Hier ist kein codex-Binary installiert, also spielt das Skript den Client. Der Servercode von osFoundry ist nicht öffentlich: Teil 1 und 2 sind der Live-Server, Teil 3 ist ein Nachbau mit eigenen Antwortformaten und eigenen Aktivitätsraten.

Warum Polling statt Push, gerade bei Codex?

Codex CLI ist kein Daemon. Es läuft, wenn du es startest, erledigt einen Turn und beendet sich oder wartet auf dich. Ein Push-Kanal bräuchte etwas, das zwischen deinen Sessions zuhört, und auf einem Laptop ist das oft: nichts. Ein Sequenz-Cursor passt zu diesem Ablauf. Der Client merkt sich einen Integer pro Ressource, kommt wieder, wann er will, und fragt, was danach passiert ist.

Die Millisekunden oben sind aus Japan auf einem Apple M1 gemessen und sagen deshalb mehr über die Route als über den Server. Wichtiger ist der Rate-Limit-Header. Nicht authentifizierte Antworten tragen X-RateLimit-Limit: 2000 mit einem Reset etwa 61 s in der Zukunft. Ob ein angemeldeter Client dasselbe Budget bekommt, wurde nicht geprüft, aber selbst 30 Ressourcen alle 30 s zu pollen sind 60 Requests pro Minute. Was einen Watcher auf Codex-Basis begrenzt, ist nicht die Serverlast, sondern seine eigenen Turns: Jeder Poll ist ein Tool-Call, den das Modell absetzen und lesen muss.

Wie findet ein Client heraus, was er pollen darf?

Mit zwei Calls, und die Schemas verraten viel über das Design:

list_granted_resources   osfoundry:read  ro=True required=[] props=[]
list_project_resources   osfoundry:read  ro=True required=['project_id', 'resource_type'] props=['project_id', 'resource_type', 'search', 'offset', 'limit']
poll_resource_events     osfoundry:read  ro=True required=['resource_type', 'resource_id'] props=['resource_type', 'resource_id', 'after_sequence', 'limit']
59 tools on server; these three = 591 of 12662 tokens (4.7%, cl100k_base)
poll_resource_events types: 10 | list_project_resources types: 9 | only pollable: ['graph_session']
Enter fullscreen mode Exit fullscreen mode

list_granted_resources nimmt überhaupt keine Argumente, und das Schema schließt aus, dass eins dazukommt:

list_granted_resources   no arguments           ok
list_granted_resources   project_id passed      REJECT additionalProperties
Enter fullscreen mode Exit fullscreen mode

Hier sagt der Server dem Client, was er hat, nicht der Client fragt nach etwas. Die Instructions von initialize beginnen mit "Use list_granted_resources first." Die Antwort besteht aus den Projekten, die der Verbindung hinzugefügt wurden, und allen einzeln freigegebenen Ressourcen. Im Webclient (ExternalAssistantsPanel.tsx) sind das der Bereich Projects und "Older individual access".

list_project_resources braucht einen resource_type. Ein Listing "alles in diesem Projekt" gibt es nicht. Ein vollständiges Inventar kostet also einen Call pro Typ, neun Typen, und mehr, wenn ein Typ über 50 Einträge hat (limit maximal 50, Paging über offset). Die Beschreibung ergänzt die einzige Ausnahme beim Projektzugriff: "Ordinary Message channels require a separate invitation."

graph_session lässt sich pollen, aber nicht listen. Graphs liegen auf dem Desktop des Owners und kommen über list_graph_sessions herein, und auch das erst, nachdem der Owner sie in der Desktop-App freigegeben hat.

Warum gehört der Cursor dem Client und nicht dem Server?

osFoundry hat beide Varianten. wait_for_messages im Agent-Netzwerk hält einen Cursor für dich: "Without after_sequence it resumes where your last call stopped", und es macht Long Polling mit timeout_s bis 50. poll_resource_events hat weder das eine noch das andere. timeout_s wird abgelehnt, und die Beschreibung sagt nur "resume from", also bringt der Client die Zahl mit:

wait_for_messages props: ['after_sequence', 'timeout_s', 'limit'] timeout_s max 50 | poll_resource_events has timeout: False
poll_resource_events     timeout_s 30           REJECT additionalProperties
Enter fullscreen mode Exit fullscreen mode

Ein serverseitiger Cursor ist bequem, solange es nur einen Listener gibt. Er bricht, sobald sich zwei Clients eine Verbindung teilen, und genau das passiert, wenn du Codex in zwei Terminals mit demselben Login öffnest. Der Nachbau schickt 600 Channel-Events an zwei Terminals vorbei, die zufällig abwechselnd pollen:

two terminals, client cursor: A saw 600/600, B saw 600/600
two terminals, server cursor: A saw 305/600, B saw 295/600
Enter fullscreen mode Exit fullscreen mode

Mit einem serverseitigen Cursor pro Verbindung schiebt jedes Terminal ihn für das andere weiter, und jedes sieht ungefähr die Hälfte. Mit after_sequence im Request sehen beide alles, und der Server speichert überhaupt keinen Zustand pro Leser. Für das Log einer Ressource, das mehrere Tools unabhängig voneinander lesen, ist der Client der richtige Besitzer des Cursors.

Das Schema macht after_sequence nicht zur Pflicht und sagt auch nicht, was ein Weglassen bedeutet. Verlass dich nicht darauf. Speichere die Zahl und schick sie immer mit.

Wie viele Calls kostet eine volle Seite?

limit endet bei 100. Nach einem Bulk-Import können bei einer Notiz Hunderte Events warten. Leerlesen heißt: ab der letzten Sequenz erneut aufrufen, bis eine Seite unvollständig zurückkommt:

burst of 350 events, limit 100:  4 calls to drain, cursor ends at 350
burst of 350 events, limit  50:  7 calls to drain, cursor ends at 350
burst of 350 events, limit  20: 18 calls to drain, cursor ends at 350
Enter fullscreen mode Exit fullscreen mode

Frag nach 100. Die Antwort ist im Nachbau 17 Tokens groß bei einer leeren Seite und 41 bei einem Event, gezählt mit cl100k_base. Das sind die Formate des Nachbaus, nicht die von osFoundry, aber die Größenordnung reicht als Argument: Ein leerer Poll ist billig zu lesen, die Kosten des Pollings stecken also vor allem in der Zahl der Turns.

Wie oft sollte Codex 30 Ressourcen pollen?

Der Nachbau gibt einem Projekt 30 Ressourcen der neun listbaren Typen mit ungleich verteilter Aktivität: 3 aktive (ein Event pro Minute), 7 mittlere (eins alle 10 Minuten), 20 ruhige (eins alle 2 Stunden). Diese Raten sind eine Annahme. Simuliert wird ein 8-Stunden-Tag mit fünf Seeds auf einer simulierten Uhr:

every 30 s, all 30                 calls/hour   3600  empty  94.7%  seen late median   14.9 s  p90   27.0 s  max     30 s  (1843 events/day)
every 5 min, all 30                calls/hour    360  empty  78.0%  seen late median  150.9 s  p90  271.1 s  max    300 s  (1843 events/day)
30 s, x2 when empty, cap 10 min    calls/hour    502  empty  70.2%  seen late median   34.9 s  p90  206.4 s  max    599 s  (1843 events/day)
30 s, x2 when empty, cap 30 min    calls/hour    420  empty  65.3%  seen late median   35.6 s  p90  238.4 s  max   1799 s  (1843 events/day)
server window for comparison: 2000 requests per ~61 s
Enter fullscreen mode Exit fullscreen mode

Fest alle 30 s ist schnell und zu 95% verschwendet: 3,600 Calls pro Stunde, fast alle leer. Fest alle 5 Minuten ist billig und überall langsam, auch auf dem aktivsten Channel. Backoff pro Ressource, also das Intervall einer Ressource nach jedem leeren Poll zu verdoppeln und beim ersten Event zurückzusetzen, kommt nah an die Call-Zahl des billigen Plans heran, mit einem Median der Verzögerung nah am schnellen. Das liegt daran, dass die aktiven Ressourcen bei 30 s bleiben und die ruhigen nach außen wandern. Der Preis ist das Limit: Bei 30 Minuten kann die erste Änderung einer ruhigen Ressource eine halbe Stunde warten.

Für Codex spricht diese Tabelle vor allem dagegen, überhaupt einen Timer laufen zu lassen. Wenn jemand fragt "Was hat sich geändert?", kostet ein Durchgang mit gespeicherten Cursorn einen Call pro beobachteter Ressource und antwortet ohne Verzögerung für alles bis zu diesem Moment. Der Backoff-Plan ist für ein Skript gedacht, das Codex oder irgendetwas anderes dauerhaft beobachten lässt.

Was passiert, wenn der Owner den Zugriff entzieht?

Der Owner entfernt das Projekt bei 4 h 07 min. Alle 30 Cursor zeigen jetzt auf Ressourcen, die die Verbindung nicht lesen kann. Die Frage ist, wie lange der Client sie noch pollt:

owner removes the project at 4 h 07 min (30 resources lose access):
  re-list grants every 10 min    failed polls after removal  180, list_granted_resources calls 25
  re-list grants every 60 min    failed polls after removal 3180, list_granted_resources calls 5
  re-list grants on first error  failed polls after removal    1, list_granted_resources calls 5
Enter fullscreen mode Exit fullscreen mode

Die Freigaben per Timer zu prüfen kostet entweder Calls oder verbrennt Tausende fehlgeschlagene Polls. Den ersten abgelehnten Poll als "Freigaben neu lesen" zu behandeln kostet einen Fehler und ein zusätzliches list_granted_resources. Danach wirfst du jeden Cursor weg, dessen Ressource nicht mehr gelistet ist. Fügt der Owner das Projekt später wieder hinzu, beginnt der Client diese Ressourcen von vorn.

Wo stoppen die Schemas einen Client?

Die restlichen Fälle, geprüft mit jsonschema (Draft 2020-12) gegen die Live-inputSchemas:

list_project_resources   no resource_type       REJECT required
list_project_resources   limit 50               ok
list_project_resources   limit 51               REJECT maximum: limit
list_project_resources   graph_session          REJECT enum: resource_type
list_project_resources   search 101 chars       REJECT maxLength: search
poll_resource_events     no after_sequence      ok
poll_resource_events     graph_session          ok
poll_resource_events     after_sequence -1      REJECT minimum: after_sequence
poll_resource_events     limit 100              ok
poll_resource_events     limit 101              REJECT maximum: limit
poll_resource_events     resource_type project  REJECT enum: resource_type
Enter fullscreen mode Exit fullscreen mode

resource_type: "project" probieren die meisten als Erstes. Ein Projekt ist aber keine Ressource mit Log. Es ist die Freigabe, die Ressourcen überhaupt sichtbar macht.

Das ganze Skript brauchte 20.2 s Wall-Clock-Zeit auf dem M1, 40 Netzwerk-Calls inklusive. Die User-CPU-Zeit lag bei 0.91 s.

Wenn du so etwas selbst baust

  • Führe ein Log pro Ressource und lass den Client den Cursor halten. Dann können sich zwei Leser auf einer Verbindung keine Events gegenseitig wegnehmen, und der Server speichert keinen Leser-Zustand.
  • Gib der Freigabeliste ein eigenes Tool ohne Argumente und lass den Client dort anfangen. Discovery sollte nur zurückgeben, was der Owner freigegeben hat.
  • Begrenze die Seitengröße und sag, wie man leerliest (ab der letzten Sequenz erneut aufrufen, bis eine Seite unvollständig ist).
  • Backoff pro Ressource, nicht global. Aktive Ressourcen bleiben schnell, ruhige werden billig.
  • Beim ersten abgelehnten Poll die Freigaben neu lesen und verschwundene Cursor verwerfen, statt sie erneut zu versuchen.
  • Den Cursor erst zurückschreiben, wenn du auf die Events reagiert hast, damit ein Absturz sie wiederholt statt sie zu überspringen.

Wie osFoundry entscheidet, welche Tools eine Verbindung bekommt, steht auf der Seite zum Tool-Scope. Die Kurzdefinition des Protokolls findest du im Glossareintrag zu MCP.

Top comments (0)