DEV Community

Cover image for Why does Claude get a queue ticket instead of a result when it runs an osFoundry Graph?
BBT
BBT

Posted on

Why does Claude get a queue ticket instead of a result when it runs an osFoundry Graph?

This is the tool a Claude chat calls to start a Graph in osFoundry, as the public MCP endpoint lists it. Fetched with an unauthenticated tools/list on 2026-11-08 and trimmed:

{"name": "run_graph_session",
 "description": "Queue a Graph run on the owner's online desktop in a connected project. Retrieve progress and result with get_graph_run. The desktop can go offline, and local tool approvals still apply.",
 "inputSchema": {
   "properties": {
     "graph_session_id": {"type": "string"},
     "arguments": {"type": "object"},
     "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}},
   "required": ["graph_session_id", "idempotency_key"],
   "additionalProperties": false},
 "annotations": {"readOnlyHint": false, "destructiveHint": true},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]}
Enter fullscreen mode Exit fullscreen mode

It has three siblings. list_graph_sessions (project_id, limit 1–50) and get_graph_run (command_id) are read-only under osfoundry:read. cancel_graph_run (command_id) is under osfoundry:run, like the start call. Two real responses from a connected account, IDs removed:

list_graph_sessions {"project_id": "…"}   → {"project_id": "…", "sessions": []}
get_graph_run {"command_id": "<unknown>"}  → error: Graph run not found or access removed
Enter fullscreen mode Exit fullscreen mode

The empty list is the default. Nothing appears there until the owner shares a session from the desktop. The verb in the description is "Queue": the tool returns before the Graph has started, and the rest of this post is about why, and about what that costs the agent on the other end.

What is a Graph session, and why can't the server just run it?

A Graph is a saved JavaScript program that the desktop's fleet engine runs: conditional paths, parallel agents, bounded retries, calls to other Graphs. In apps/web/src-tauri/src/graph_config.rs, run_graph_now is the Tauri command the Graph screen uses. It hands the script to run_fleet_script with a hard token ceiling that refuses the next wave of agents once it's reached. A Graph session is the durable identity for one saved Graph on one device, gs_ plus 32 hex characters, in the local graph_sessions table.

The engine, the agents the script spawns, local models, folders and per-tool approval settings are all in the desktop process. A server-side runner would need copies of all of them. The feature index says it plainly: reading Graphs works in the browser, execution needs the desktop.

How does a request reach a desktop nobody can connect to?

It doesn't. The desktop asks for work. apps/web/src/hooks/useConnectedGraphRelay.ts, mounted once in the main desktop window, does this:

  1. Every 20 seconds (and right after sharing changes), skip if the previous pass is still busy.
  2. Re-send any completions from a local outbox that didn't reach the server last time.
  3. For each project the owner enabled, POST /external-assistants/graph-relays/sync with metadata for the sessions ticked for that project, at most 100: name, description, slug, version, status. The response carries the commands queued for that project.
  4. For each command, re-check that the project is still enabled, the session still ticked, and the session still has the same slug. If not, complete it as cancelled with "Graph session is no longer shared with this project".
  5. Otherwise run it through run_graph_now on the channel graph:external:<command id>, with a 3-second control poll for cancellation, then post the bounded result.

So list_graph_sessions shows what the last sync uploaded, and run_graph_session writes a row the desktop will find on its next pass. The command IDs are UUIDs. ConnectedGraphProjectBinding refuses anything else before it writes the channel-to-project row that files the run under the connected project.

The rebuild

No Claude binary runs on this Mac, so sim.py plays all three parts on a simulated clock. The desktop half is a port of the relay above: 20 s poll, busy skip, serial commands, re-check before running, 3 s control poll, outbox, the 100,000-character result bound. The server half is a stand-in, because its code isn't public. It implements what the tool descriptions say: a queue, results readable only by the agent that started the run, cancel for queued vs active. It also implements the idempotency behaviour that osFoundry's CLI guide asks for ("reuse idempotency keys after a lost response"). The client is a scripted Claude.

Graph run times are synthetic: lognormal, median 60 s, at least 5 s. Each experiment uses 500–2,000 seeded runs, and the relay's phase is random relative to the request. Python 3.9.6 on an Apple M1, 2.8 s of wall time for everything. Output is in code/run.out.

How long does a run sit in the queue?

pickup wait (queued->started), n=2000: mean 9.8 s, median 9.8 s, p95 18.9 s, max 20.0 s
Enter fullscreen mode Exit fullscreen mode

Up to one poll interval, half of one on average. That's the price of an outbound-only desktop. It holds no open port and no long-lived socket, and a closed laptop is just a relay that hasn't polled.

The other cost is less obvious. The relay runs commands one after another and skips its timer while busy. Five runs queued together finish in sequence:

Runs queued at once First result (median) Last result (median) Last result (p95)
1 74 s 74 s 254 s
3 73 s 233 s 483 s
5 75 s 399 s 776 s

An agent that fans out five Graph runs to save time gets the slowest possible schedule. One desktop is one worker. If you want parallelism, put it inside the Graph, where the engine has max_concurrency and a token ceiling for it.

How often should the agent call get_graph_run?

There's no push channel back to Claude either. Every strategy trades calls against how late the agent notices the result. 2,000 runs per row:

Client poll strategy Calls per run (mean) p95 Result noticed late (mean) p95
every 2 s 47.1 118 1.0 s 1.9 s
every 5 s 19.1 48 2.5 s 4.8 s
every 15 s 6.7 16 7.5 s 14.2 s
every 30 s 3.6 8 14.9 s 28.4 s
backoff 2, 4, 8 … 30 s 6.6 11 14.8 s 28.4 s
wait 20 s, then every 10 s 8.8 23 5.0 s 9.6 s

Polling every 2 s spends 47 tool calls, and so 47 trips through the model's context, to save a few seconds on a job that takes a minute. Exponential backoff looks clever and loses here: it makes as many calls as a flat 15 s poll and notices the result twice as late, because it's already at its 30 s cap by the time a typical Graph finishes. The last row uses what the relay tells you. Nothing can start in the first poll interval anyway, so waiting about one interval before the first check costs almost nothing.

What does cancel actually stop?

cancel at a random 0-40 s after queueing, n=2000: still queued 523, already running 1370
  queued : terminal after 0.0 s (graph never started)
  running: terminal after mean 2.0 s, p95 3.3 s, max 3.5 s
Enter fullscreen mode Exit fullscreen mode

A queued cancel is a server-side state change. The desktop never sees the command and no tokens are spent. A running cancel is a flag the desktop reads on its 3-second control poll, and then it calls fleet_cancel_run on the run ID it got from the fleet_started event. The relay sends that cancel once and keeps the outcome as cancelled, even if the Graph manages to finish between the flag and the stop.

What does the idempotency key buy?

idempotency_key is required, 8–128 characters. 200 runs per repetition, with 30% of replies dropped so the client retries:

Retry behaviour Calls sent Graph runs queued
new key on retry 266 / 302 / 286 266 / 302 / 286
same key on retry 270 / 291 / 275 200 / 200 / 200

For a Graph that drafts emails or writes files, a duplicate isn't a wasted minute, it's a second set of side effects. That's why the key is required in the schema and the tool is marked destructiveHint: true.

What if the desktop drops off?

desktop offline 600 s: statuses ['queued'], executed 0
  back online: first start +3 s, all 5 ['completed'] by +131 s, executed 5
completion POST lost once, outbox=on : status completed, delivered 40 s after start
completion POST lost once, outbox=off: status running, never delivered
Enter fullscreen mode Exit fullscreen mode

Queued work survives an offline desktop, because the queue is on the server. The weaker point is the way back. If the desktop finished a Graph and its POST was lost, a relay that only held the result in memory would leave the run stuck at running for good. The real relay writes each completion to localStorage before posting it and removes it only after a 2xx, so the next pass, 20 s later, delivers it.

How big can the result be?

The relay posts {execution_id, status, result, total_tokens, reported_cost} if JSON.stringify of it is at most 100,000 characters. Otherwise it posts {execution_id, status, result_truncated: true}. Errors are cut to 2,000 characters. The limit counts characters, not bytes:

result text that still fits (ascii): 99,899 chars = 99,899 UTF-8 bytes
result text that still fits (japanese): 99,899 chars = 299,697 UTF-8 bytes
Enter fullscreen mode Exit fullscreen mode

The cut is all or nothing. There's no half result to misread, and the full run record stays in the desktop's history under that execution_id. Either way it's a lot of text to put in front of a model, so a Graph meant to be called by an agent should end in a summary, not a dump.

Who else can see the run?

another agent reads the run: {'error': 'Graph run not found or access removed'}
Enter fullscreen mode Exit fullscreen mode

In the stand-in, a second connection asking for Claude's command_id gets the same error the live server returned for a made-up ID. get_graph_run and cancel_graph_run are both described as working on runs "started by this Connected agent". Not telling "not yours" apart from "doesn't exist" means a leaked ID confirms nothing.

If you wire this up yourself

  • Let the machine that owns the tools pull work. An outbound poll survives NAT, sleep and laptops closing. Size the interval knowing that every caller waits half of it on average.
  • Return a ticket from the start call, and say in the tool description which tool reads the result. The model can't infer a queue from the name.
  • Put the first status check about one pickup interval out. Flat polling beats exponential backoff when job lengths cluster around a minute.
  • Require an idempotency key on the start call. With side-effecting jobs, a retried request without one is a second run.
  • Persist completions on the worker before you send them. The result is the part of the round trip you can't recompute cheaply.
  • Re-check consent on the worker right before running, not only when the job is queued. Sharing can change in between.

osFoundry's short definition of the protocol underneath is in its MCP glossary entry, and the wider pattern Graphs belong to is in the agent orchestration entry.

Top comments (0)