DEV Community

Cover image for Codex CLI 該怎麼等待它排進 osFoundry session 的 run?
BBT for BBT 繁體中文

Posted on

Codex CLI 該怎麼等待它排進 osFoundry session 的 run?

這是 osFoundry MCP server 裡唯一一個不需要任何人點擊就會開始工作的工具。不論 client 有沒有登入,tools/list 回傳的就是這樣:

{"name": "request_session_run",
 "description": "Queue a bounded goal for an agent, team, or loop session where this assistant is an invited runner. The osFoundry runtime executes the task when available.",
 "inputSchema": {"properties": {
     "session_type": {"enum": ["agent_session", "team_session", "loop_session"]},
     "session_id": {"type": "string"},
     "goal": {"type": "string", "maxLength": 10000},
     "max_rounds": {"type": "integer", "minimum": 1, "maximum": 10},
     "idempotency_key": {"type": "string"}},
   "required": ["session_type", "session_id", "goal", "idempotency_key"],
   "additionalProperties": false},
 "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]}
Enter fullscreen mode Exit fullscreen mode

這段 description 裡有兩個詞決定了 client 要怎麼用它。"Invited runner" 是一個會在三個地方檢查的權限。"When available" 代表呼叫會在工作開始前就返回,所以 client 得回頭來拿結果。這篇文章會逐一說明這兩點,以及 Codex 之後用來和 session 溝通的信箱,並附上在這台 Mac 上跑的 Python 版 client 端重建。

登入前 client 能知道什麼?

其實不少。initialize、tools/list 和 protected-resource metadata 都不需要 token 就會回應:

$ uv run --with tiktoken --with jsonschema python3 runner_sim.py
== Part 1: live wire, 10 calls each, ms ==
initialize             HTTP 200  median  375.1  p90  726.0
tools/list             HTTP 200  median  551.1  p90  755.5
protected-resource     HTTP 200  median  325.4  p90  578.8
tools/call (no token)  HTTP 401  median  454.2  p90  492.8
scopes_supported: ['osfoundry:read', 'osfoundry:write', 'osfoundry:run', 'osfoundry:models']
Enter fullscreen mode Exit fullscreen mode

這是在日本用 Apple M1 量的,所以這些毫秒數反映的不只是 server,也包含網路路徑。osfoundry:run 是獨立公開的 scope,和 osfoundry:write 分開。Codex 設定如果沒有把它放進 scopes,就永遠拿不到能排入 run 的 token,之後被指派什麼 seat 都一樣。

Runner 迴圈只用到 server 59 個工具中的五個:read_session_state、request_session_run、read_session_task_result、post_session_note 和 read_session_mail。它們佔整個清單 10,197 個 token 中的 821 個(8.1%,用 cl100k_base 計算,對 Codex 自己的 tokenizer 來說只是近似值)。在 enabled_tools 只列出這五個,就能讓另外 54 個工具不進 prompt。

誰決定 Codex 是 runner?

三個不同的關卡,一次呼叫三個都要過:

  1. Token。request_session_run 宣告了 osfoundry:run。登入頁面把這個 scope 顯示為 "Run sessions where you grant a runner role"。
  2. Grant。在 osFoundry 的 web client(ExternalAssistantsPanel.tsx)裡,session grant 帶有幾個 action,標籤分別是 View、Reply、Edit 和 "Start work"。Grant 的角色下拉選單只有在 grant 包含 write 時才會出現 Contributor,包含 run 時才會出現 Runner。
  3. Seat。Owner 會為每個 session 儲存一個角色,client 以 POST /external-assistants/{connection}/sessions/{session_type}/{session_id} 搭配 {"role": "observer" | "contributor" | "runner"} 送出。下拉選單上顯示為 Viewer、Contributor 和 Runner。

腳本重建了這個檢查。角色和工具的對應來自面板的說明文字("Contributor to post notes and propose handoffs, or Runner to also request runs"),每個工具的 scope 則來自 live schema。osFoundry 的 server 程式碼沒有公開,所以這是規則的重建,不是實際抓到的 server 拒絕回應:

grant actions ['read']                    -> roles offered ['observer']
grant actions ['read', 'write']           -> roles offered ['observer', 'contributor']
grant actions ['read', 'write', 'run']    -> roles offered ['observer', 'contributor', 'runner']
tool                      observer   contributor  runner
read_session_state        yes        yes          yes
request_session_run       -          -            yes
read_session_task_result  yes        yes          yes
post_session_note         -          yes          yes
read_session_mail         yes        yes          yes
request_session_run       seat=contributor  scopes=read+run+write  -> seat 'contributor' cannot call it
request_session_run       seat=runner       scopes=read+write      -> token lacks osfoundry:run
Enter fullscreen mode Exit fullscreen mode

對寫 client 的人來說,這個區分很重要。request_session_run 被拒絕有兩種不同的解法:帶著 scope 重新登入(你這邊),或請 owner 給 seat(對方那邊)。要回報是哪一種,而不是一直重試。

從呼叫到拿到結果之間發生了什麼?

呼叫會排入一個 task 然後返回。Web client 的型別說明了這個 task 是什麼:一個 task_type: "external_assistant" 的 AgentTask,API 的 task 建立路由會拒絕這個型別,因為只有 server 能啟用它。它的狀態會經過 pending、running、input_required、completed、failed 或 cancelled。在 session 的 Trigger 分頁裡,它的標籤是 "Requested by an outside assistant"。

誰來接手取決於 session 跑在哪裡。如果 session 在 owner 的桌面 app 上,agent_session_runtime.rs 把時間寫得很清楚:

  • DEFAULT_POLL_INTERVAL_MS 是 15,000。到期的 task 會在下一次 poll 時以 lease 認領。
  • 連續四次 poll 都沒找到東西後(IDLE_CYCLES_BEFORE_BACKOFF),間隔會沿著 IDLE_BACKOFF_LADDER_MS 拉長:30 s、60 s,然後 300 s。檔案裡有一段註解直接講明代價:server 啟用的 trigger "arriving at a fully idle desktop waits up to five minutes"(到達完全閒置的桌面時最多要等五分鐘)。只要有人在 app 裡做任何操作,迴圈大約一秒內就會被喚醒。
  • 已經 "already has a run in flight" 的 session 會延後它到期的 task。同一個 session 的 run 不會重疊。
  • Run 結束時,結果會由 runtime 帶著 task_id 寫進 session mail。主旨會說明它是正常完成("Task … result"),還是停在某個上限("goal not fully met"、"stopped at your token ceiling")。
  • Goal 會被當成不可信的輸入來檢查。provenance_for_task_type 只有對 manual,也就是 owner 在 app 裡自己啟動的 run,才會回傳 Operator。其他所有型別,包括這一個,都要經過 osGuard。

任何 client 都該特別處理 input_required。它代表 run 向 osFoundry 裡的某個人提出了問題,正卡在等回答。它仍然在執行中。繼續等,並告訴你的使用者 osFoundry 那邊需要有人回答。

Client 應該多久 poll 一次?

Codex 沒辦法在工具呼叫裡 sleep,而一個卡住好幾分鐘的 MCP 工具呼叫會撞上 client 的 timeout。所以實際可行的做法是對 read_session_task_result 跑 polling 迴圈。問題在於間隔要設多少。

腳本的第 4 部分用虛擬時鐘模擬:2,000 次 run,接手延遲取自 runtime 本身的間隔,run 的長度取自中位數 90 s 的對數常態分布。Run 長度是假設值,所以絕對數字只當參考。每種策略都會計算它發出的呼叫次數,以及 run 完成後多久才發現:

runtime active    pending wait: median   7.5 s  p90  13.5 s  max  15.0 s
  poll every 5 s            calls/task median   20  p90   41   noticed late by median   2.5 s  p90   4.5 s
  poll every 30 s           calls/task median    4  p90    7   noticed late by median  15.0 s  p90  26.8 s
  poll 5 s x1.5, cap 60 s   calls/task median    6  p90    8   noticed late by median  21.5 s  p90  48.8 s
  poll 15 s x2, cap 120 s   calls/task median    3  p90    4   noticed late by median  41.8 s  p90 107.2 s
  poll first at 60 s, then 15 s calls/task median    4  p90   11   noticed late by median   7.9 s  p90  14.6 s
runtime idle-300  pending wait: median 152.4 s  p90 273.7 s  max 300.0 s
  poll every 5 s            calls/task median   51  p90   78   noticed late by median   2.5 s  p90   4.5 s
  poll every 30 s           calls/task median    9  p90   13   noticed late by median  14.8 s  p90  26.6 s
  poll 5 s x1.5, cap 60 s   calls/task median    9  p90   11   noticed late by median  28.4 s  p90  52.9 s
  poll 15 s x2, cap 120 s   calls/task median    5  p90    6   noticed late by median  59.0 s  p90 107.7 s
  poll first at 60 s, then 15 s calls/task median   14  p90   23   noticed late by median   7.5 s  p90  13.4 s
Enter fullscreen mode Exit fullscreen mode

從中可以看出三件事。

Exponential backoff 在這裡不划算。它是 polling 的直覺做法,但這些 run 只跑幾分鐘,不是幾小時。"15 s x2, cap 120 s" 比固定 30 s 省一兩次呼叫,但中位數上晚 40 到 60 s 才發現結果。

在活躍的 runtime 上,最好的取捨是讓第一次等待接近 run 的典型長度:中位數四次呼叫,8 s 內就發現結果。Run 不可能完成之前的每一次呼叫都是浪費。

Runtime 本身的狀態比 client 的策略更重要。在已經閒置的桌面上,中位數的 run 會在 pending 停留 152 s 才被認領,任何 polling 間隔都改變不了。如果你盯著 read_session_state,發現幾分鐘後 task 還是 pending,那是 runtime 的閒置 backoff,不是 run 卡住。

在同一個 session 上連續排入的 run 會依序排隊:

  queued run 1: starts at   13.9 s, done at  121.9 s
  queued run 2: starts at  135.9 s, done at  346.2 s
  queued run 3: starts at  357.7 s, done at  406.6 s
Enter fullscreen mode Exit fullscreen mode

如果 Codex 排入三個 goal,再用 5 s 的計時器同時 poll 三個,其中兩個會花好幾分鐘一直回傳 pending,原因只是在排隊。

為什麼 note 不能啟動 run?

post_session_note 只需要 osfoundry:write 和 Contributor seat。如果 note 能喚醒 session 的 agent 去跑 run,那每個 Contributor 其實都是換了名字的 Runner。Description 明確排除了這點:"Other agents can reply to the returned external sender slug; this does not trigger an automatic run."

所以 note 是訊息,不是觸發器。它會帶著 connection 的顯示名稱進到 session mail。Web client 會在 Team mail 裡顯示 external_sender_name 和 "outside assistant" 標籤。Agent 下次執行時可以回覆它。

信箱會回傳什麼?

read_session_mail 的說明是 "bounded notes addressed to this connection and notes it posted",另外還有 "Internal agent analysis is excluded."。Schema 把每頁限制在 limit 1 到 20,並用 before_id 往前翻頁。

腳本模擬了一個忙碌 team session 的 mail,共 409 列,涵蓋 web client 認得的訊息類型(analysis、tool_call、tool_result、agent_note、broadcast),並套用說明中的過濾規則:

session mail rows 409; visible to the connection 22 (addressed to it 13, posted by it 9)
  not visible, by type: {'tool_call': 97, 'tool_result': 62, 'agent_note': 25, 'analysis': 176, 'broadcast': 27}
  limit 20: 2 calls to walk back through all 22 notes
  limit 10: 3 calls to walk back through all 22 notes
  limit  5: 5 calls to walk back through all 22 notes
Enter fullscreen mode Exit fullscreen mode

列數是模型給的。重點在形狀:connection 看得到的只是一小塊,所以 limit: 20 通常一兩次呼叫就能全部拿到。Run 的結果也不在這一小塊裡。在桌面 runtime 的程式碼中,結果 mail 發出時沒有指定收件者,所以不是寄給 connection 的。結果用 read_session_task_result 讀,對話用 read_session_mail 讀,不要期待其中一個能取代另一個。

Schema 會在哪裡擋下 client?

針對 live inputSchema 測了十三個案例,用 jsonschema(Draft 2020-12)驗證:

request_session_run       goal 10,000 chars              ok
request_session_run       goal 10,001 chars              REJECT maxLength: goal
request_session_run       max_rounds 10                  ok
request_session_run       max_rounds 11                  REJECT maximum: max_rounds
request_session_run       extra field 'context'          REJECT additionalProperties
request_session_run       session_type 'graph_session'   REJECT enum: session_type
read_session_task_result  no task_id                     REJECT required
read_session_mail         limit 20                       ok
read_session_mail         limit 0                        REJECT minimum: limit
read_session_mail         limit 50                       REJECT maximum: limit
post_session_note         subject 200, body 10,000       ok
post_session_note         body 10,001                    REJECT maxLength: body
post_session_note         to: '@writer'                  REJECT additionalProperties
Enter fullscreen mode Exit fullscreen mode

其中兩個很容易踩到。Run 沒有 context 欄位,所以背景資訊要放進 goal,10,000 字元的上限是兩者合計。Note 沒有 to,所以沒辦法指定給某一個 agent。每則 note 都是發給 session,agent 再回覆到 connection 的 sender slug。

整個腳本在 M1 上的 wall-clock 時間是 19.3 s,包含 40 次網路呼叫。User CPU 時間是 0.47 s,所以模擬本身的成本可以忽略。

如果你要自己串接

  • 把 "start work" 公開成獨立的 OAuth scope,並且讓角色同時受 grant 和 token 把關,這樣不論是呼叫端還是過期的 grant,都沒辦法自己變成 runner。
  • 立即回傳 task id,並給呼叫端一個便宜的狀態查詢。不要讓 MCP 工具呼叫去等 agent run 跑完。
  • 在文件或狀態裡公開 runtime 實際的接手行為(poll 間隔、每個 session 一次一個 run),讓 client 用合理的頻率 poll,而不是用猜的。
  • 外部呼叫端送來的任何東西都當成不可信的輸入來檢查,即使呼叫端握有 runner seat。
  • 把 note 和 run 分開:寫 note 的人不應該能喚醒 run,讀回的內容也應該排除 session 的內部分析。

每個 session 的 run 怎麼排隊,請看 agent task queue 頁面,session 的其他啟動方式則在 agent triggers。

Top comments (0)