這是 Claude 對話在 osFoundry 裡啟動 Graph 時呼叫的工具,內容取自公開 MCP 端點的列表。2026-11-08 用未驗證的 tools/list 取得,有刪減:
{"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"]}]} 它還有三個兄弟工具。list_graph_sessions(project_id、limit 1–50)和 get_graph_run(command_id)是唯讀的,屬於 osfoundry:read。cancel_graph_run(command_id)和啟動呼叫一樣屬於 osfoundry:run。以下是已連線帳號的兩個真實回應,ID 已移除:
list_graph_sessions {"project_id": "…"} → {"project_id": "…", "sessions": []} get_graph_run {"command_id": "<unknown>"} → error: Graph run not found or access removed 空列表是預設狀態。在擁有者從桌面版分享 session 之前,這裡不會出現任何東西。說明裡的動詞是 "Queue":工具在 Graph 開始之前就已經回傳了。本文接下來要談的就是為什麼會這樣,以及另一端的 agent 要為此付出什麼代價。
Graph session 是什麼?為什麼伺服器不能直接執行?
Graph 是一支存起來的 JavaScript 程式,由桌面版的 fleet 引擎執行:條件分支、平行 agent、有上限的重試、呼叫其他 Graph。在 apps/web/src-tauri/src/graph_config.rs 裡,run_graph_now 是 Graph 畫面使用的 Tauri command。它把腳本交給 run_fleet_script,並帶著一個硬性的 token 上限,一旦達到就拒絕下一波 agent。Graph session 是某台裝置上某個已存 Graph 的持久識別,格式是 gs_ 加 32 個十六進位字元,存在本機的 graph_sessions 資料表裡。
引擎、腳本產生的 agent、本機模型、資料夾,以及每個工具的核准設定,全都在桌面版的行程裡。伺服器端的執行器得把這些全部複製一份才行。功能索引寫得很直白:讀取 Graph 在瀏覽器就能做,執行則需要桌面版。
請求要怎麼送到一台沒人連得進去的桌面?
送不到。是桌面主動來要工作。apps/web/src/hooks/useConnectedGraphRelay.ts 在桌面版主視窗只掛載一次,做的事情是:
- 每 20 秒一次(分享設定變更後也會立刻跑一次),如果上一輪還在忙就跳過。
- 重送本機 outbox 裡上次沒送到伺服器的完成結果。
- 對擁有者啟用的每個專案,呼叫
POST /external-assistants/graph-relays/sync,附上該專案勾選的 session 的 metadata,最多 100 個:名稱、說明、slug、版本、狀態。回應裡會帶回該專案排隊中的 command。 - 對每個 command 再確認一次:專案仍然啟用、session 仍然勾選、slug 也沒變。只要有一項不符,就以
cancelled結束,附上 "Graph session is no longer shared with this project"。 - 否則透過
run_graph_now在 channelgraph:external:<command id>上執行,搭配 3 秒一次的控制輪詢來處理取消,最後 POST 有大小限制的結果。
所以 list_graph_sessions 顯示的是上一次 sync 上傳的內容,而 run_graph_session 寫入的是一列資料,桌面會在下一輪找到它。command ID 是 UUID。ConnectedGraphProjectBinding 在寫入把 run 歸到已連線專案底下的 channel 對專案那一列之前,會拒絕任何其他格式。
重建
這台 Mac 上沒有跑 Claude 的執行檔,所以 sim.py 在模擬時鐘上一人分飾三角。桌面這半邊是上面那個 relay 的移植:20 s 輪詢、忙碌時跳過、command 依序執行、執行前再確認、3 s 控制輪詢、outbox、結果 100,000 字元的上限。伺服器那半邊是替身,因為它的程式碼沒有公開。它實作工具說明裡寫的東西:一個佇列、只有啟動 run 的 agent 才能讀取結果、排隊中和執行中的取消行為不同。它也實作了 osFoundry 的 CLI 指南要求的冪等行為("reuse idempotency keys after a lost response")。客戶端則是照腳本走的 Claude。
Graph 的執行時間是合成的:對數常態分布,中位數 60 s,最少 5 s。每個實驗用 500–2,000 次有 seed 的 run,relay 的相位相對於請求是隨機的。Python 3.9.6、Apple M1,全部跑完 wall time 2.8 s。輸出在 code/run.out。
一個 run 會在佇列裡等多久?
pickup wait (queued->started), n=2000: mean 9.8 s, median 9.8 s, p95 18.9 s, max 20.0 s 最多一個輪詢間隔,平均半個。這是桌面只做對外連線的代價。它不開任何 port,也不維持長連線的 socket,筆電闔上就只是一個還沒輪詢的 relay。
另一個代價比較不明顯。relay 是一個接一個地執行 command,忙碌時會跳過計時器。同時排進去的五個 run 會依序完成:
| 同時排隊的 run 數 | 第一個結果(中位數) | 最後一個結果(中位數) | 最後一個結果(p95) |
|---|---|---|---|
| 1 | 74 s | 74 s | 254 s |
| 3 | 73 s | 233 s | 483 s |
| 5 | 75 s | 399 s | 776 s |
為了省時間而一次丟出五個 Graph run 的 agent,拿到的反而是最慢的排程。一台桌面就是一個 worker。想要平行化,就把它放進 Graph 裡面,引擎在那裡有 max_concurrency 和 token 上限可以用。
agent 應該多久呼叫一次 get_graph_run?
回到 Claude 的方向也沒有 push 通道。每種策略都是在呼叫次數和 agent 多晚發現結果之間取捨。每列 2,000 次 run:
| 客戶端輪詢策略 | 每個 run 的呼叫數(平均) | p95 | 發現結果的延遲(平均) | p95 |
|---|---|---|---|---|
| 每 2 s | 47.1 | 118 | 1.0 s | 1.9 s |
| 每 5 s | 19.1 | 48 | 2.5 s | 4.8 s |
| 每 15 s | 6.7 | 16 | 7.5 s | 14.2 s |
| 每 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 |
| 先等 20 s,之後每 10 s | 8.8 | 23 | 5.0 s | 9.6 s |
每 2 s 輪詢一次要花 47 次工具呼叫,也就是 47 次經過模型的 context,只為了在一個要跑一分鐘的工作上省幾秒。指數 backoff 看起來很聰明,在這裡卻輸了:呼叫次數跟固定 15 s 輪詢一樣多,發現結果卻晚了一倍,因為典型的 Graph 跑完時,它早就撞到 30 s 的上限了。最後一列利用了 relay 透露的資訊。第一個輪詢間隔內本來就不可能開始執行,所以在第一次檢查前先等大約一個間隔,幾乎沒有成本。
取消到底停掉了什麼?
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 取消排隊中的 run,是伺服器端的狀態變更。桌面根本不會看到這個 command,也不會花到任何 token。取消執行中的 run,則是一個 flag,桌面在 3 秒一次的控制輪詢時讀到它,然後用從 fleet_started 事件拿到的 run ID 呼叫 fleet_cancel_run。relay 只送一次取消,並把結果維持為 cancelled,就算 Graph 剛好在 flag 和停止之間跑完也一樣。
idempotency key 換來了什麼?
idempotency_key 是必填,8–128 個字元。每次重複 200 個 run,丟掉 30% 的回應讓客戶端重試:
| 重試行為 | 送出的呼叫數 | 排進佇列的 Graph run |
|---|---|---|
| 重試時換新 key | 266 / 302 / 286 | 266 / 302 / 286 |
| 重試時用同一個 key | 270 / 291 / 275 | 200 / 200 / 200 |
對一個會草擬 email 或寫檔案的 Graph 來說,重複執行不只是浪費一分鐘,而是第二組副作用。這就是 schema 把 key 設成必填、並把工具標成 destructiveHint: true 的原因。
桌面斷線了怎麼辦?
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 排隊中的工作在桌面離線時不會消失,因為佇列在伺服器上。比較弱的地方是回程。如果桌面跑完 Graph 但 POST 弄丟了,一個只把結果放在記憶體裡的 relay,會讓這個 run 永遠卡在 running。實際的 relay 在 POST 之前會先把每個完成結果寫進 localStorage,收到 2xx 才刪除,所以 20 s 後的下一輪會把它送出去。
結果最大可以多大?
如果 JSON.stringify 後不超過 100,000 字元,relay 就 POST {execution_id, status, result, total_tokens, reported_cost}。超過的話就 POST {execution_id, status, result_truncated: true}。錯誤訊息截到 2,000 字元。這個上限算的是字元,不是位元組:
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 截斷是全有或全無。不會有半截結果讓人誤讀,完整的 run 紀錄也會以那個 execution_id 保留在桌面的歷史裡。不管怎樣,這都是很大量的文字要丟給模型,所以設計來給 agent 呼叫的 Graph,最後應該輸出摘要,而不是整包 dump。
還有誰看得到這個 run?
another agent reads the run: {'error': 'Graph run not found or access removed'} 在替身伺服器裡,第二條連線去要 Claude 的 command_id,拿到的錯誤跟正式伺服器對一個捏造的 ID 回傳的一模一樣。get_graph_run 和 cancel_graph_run 的說明都寫著只對 "started by this Connected agent" 的 run 有效。不區分「不是你的」和「不存在」,代表外洩的 ID 什麼也證實不了。
如果你要自己串這套
- 讓擁有工具的那台機器主動拉工作。對外輪詢可以穿過 NAT、撐過休眠和闔上的筆電。決定間隔時要記得,每個呼叫者平均都要等一半。
- 啟動呼叫回傳一張單號,並在工具說明裡寫清楚由哪個工具讀取結果。模型沒辦法從名稱推斷出有佇列。
- 第一次狀態檢查放在大約一個領取間隔之後。當工作時長集中在一分鐘左右時,固定輪詢勝過指數 backoff。
- 在啟動呼叫上強制要求 idempotency key。對有副作用的工作來說,沒帶 key 的重試請求就是第二次 run。
- 在送出之前,先把完成結果持久化在 worker 上。結果是整個來回中你無法便宜地重算的部分。
- 在 worker 上、就在執行前再確認一次授權,不要只在排隊時檢查。分享設定可能在這中間改變。
osFoundry 對底層協定的簡短定義在它的 MCP 詞彙條目,而 Graph 所屬的更大模式則在 agent orchestration 條目。