DEV Community

Cover image for ¿Cómo debe esperar Codex CLI el resultado de un run que encoló en una sesión de osFoundry?
BBT for BBT Español

Posted on

¿Cómo debe esperar Codex CLI el resultado de un run que encoló en una sesión de osFoundry?

Esta es la única herramienta del servidor MCP de osFoundry que arranca trabajo sin que una persona haga clic en nada, tal como tools/list la devuelve a cualquier cliente, haya iniciado sesión o no:

{"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

Dos expresiones de esa descripción condicionan todo lo que un cliente hace con ella. "Invited runner" es una comprobación de permisos que ocurre en tres sitios. "When available" significa que la llamada vuelve antes de que empiece el trabajo, así que el cliente tiene que regresar a por el resultado. Este post repasa las dos, además del buzón que usa Codex para hablar con la sesión después, con una reconstrucción en Python del lado del cliente ejecutada en este Mac.

¿Qué puede saber un cliente antes de iniciar sesión?

Bastante. initialize, tools/list y los metadatos del protected resource responden sin 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

Medido desde Japón en un Apple M1, así que los milisegundos describen tanto la ruta como el servidor. osfoundry:run se publica como un scope propio, separado de osfoundry:write. Una configuración de Codex que lo deje fuera de scopes nunca obtiene un token capaz de encolar un run, da igual qué puesto se le asigne después.

El bucle del runner usa cinco de las 59 herramientas del servidor: read_session_state, request_session_run, read_session_task_result, post_session_note y read_session_mail. Suman 821 de los 10,197 tokens de la lista (8.1%, contados con cl100k_base, así que es una aproximación para el tokenizer propio de Codex). Poner solo esas en enabled_tools deja las otras 54 fuera del prompt.

¿Quién decide que Codex es un runner?

Tres partes distintas, y una llamada necesita las tres:

  1. El token. request_session_run declara osfoundry:run. La página de inicio de sesión muestra ese scope como "Run sessions where you grant a runner role".
  2. El grant. En el cliente web de osFoundry (ExternalAssistantsPanel.tsx), un grant de sesión lleva acciones, etiquetadas como View, Reply, Edit y "Start work". El desplegable de rol de un grant solo ofrece Contributor cuando el grant incluye write, y Runner cuando incluye run.
  3. El puesto. El owner guarda un rol por sesión, que el cliente envía como POST /external-assistants/{connection}/sessions/{session_type}/{session_id} con {"role": "observer" | "contributor" | "runner"}. El desplegable los muestra como Viewer, Contributor y Runner.

El script reconstruye esa comprobación. La relación entre rol y herramienta sale del texto de ayuda del panel ("Contributor to post notes and propose handoffs, or Runner to also request runs"), y el scope de cada herramienta, de los esquemas en vivo. El código del servidor de osFoundry no es público, así que esto es una reconstrucción de la regla, no una captura de los rechazos del servidor:

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

La separación importa a quien escribe el cliente. Un rechazo en request_session_run tiene dos arreglos distintos: volver a iniciar sesión con el scope (tu lado) o pedirle el puesto al owner (el suyo). Informa de cuál es en lugar de reintentar.

¿Qué pasa entre la llamada y el resultado?

La llamada encola una tarea y vuelve. Los tipos del cliente web muestran qué es esa tarea: un AgentTask con task_type: "external_assistant", un tipo que la ruta de creación de tareas de la API rechaza, porque solo el servidor lo arma. Su estado pasa por pending, running, input_required, completed, failed o cancelled. En la pestaña Trigger de la sesión aparece etiquetada como "Requested by an outside assistant".

Quién la recoge depende de dónde corre la sesión. Para una sesión en la app de escritorio del owner, agent_session_runtime.rs detalla los tiempos:

  • DEFAULT_POLL_INTERVAL_MS vale 15,000. Una tarea pendiente se reclama en el siguiente poll con un lease.
  • Tras cuatro polls que no encuentran nada (IDLE_CYCLES_BEFORE_BACKOFF), el intervalo sube por IDLE_BACKOFF_LADDER_MS: 30 s, 60 s y luego 300 s. Un comentario del archivo deja claro el coste: un trigger armado por el servidor "arriving at a fully idle desktop waits up to five minutes" (que llega a un escritorio totalmente inactivo espera hasta cinco minutos). Cualquier cosa que haga una persona en la app despierta el bucle en más o menos un segundo.
  • Una sesión que "already has a run in flight" aplaza sus tareas pendientes. Los runs de una misma sesión no se solapan.
  • Cuando el run termina, el resultado se escribe en el correo de la sesión desde runtime con un task_id. El asunto indica si terminó bien ("Task … result") o si se paró en un techo ("goal not fully met", "stopped at your token ceiling").
  • El goal se trata como entrada no confiable. provenance_for_task_type devuelve Operator solo para manual, un run que el owner lanzó en la app. Todos los demás tipos, este incluido, pasan por osGuard.

input_required merece una línea en cualquier cliente. Significa que el run le hizo una pregunta a una persona en osFoundry y está bloqueado esperando la respuesta. Sigue en marcha. Sigue esperando y dile a tu usuario que alguien del lado de osFoundry tiene que responder.

¿Cada cuánto debe hacer polling el cliente?

Codex no puede dormir dentro de una llamada a herramienta, y una llamada MCP que se bloquea durante minutos choca con los timeouts del cliente. Así que lo realista es un bucle de polling sobre read_session_task_result. La pregunta es el intervalo.

La parte 4 del script lo simula con un reloj virtual: 2,000 runs, el retraso de recogida sacado de los intervalos del propio runtime y la duración del run de una log-normal con mediana de 90 s. Esa duración es una suposición, así que toma los números absolutos como ilustrativos. Cada estrategia cuenta las llamadas que hace y cuánto tarda en enterarse después de que el run termine:

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

De ahí salen tres cosas.

El backoff exponencial no compensa aquí. Es el reflejo habitual para el polling, pero estos runs duran minutos, no horas. "15 s x2, cap 120 s" ahorra una o dos llamadas frente a un 30 s fijo y se entera del resultado entre 40 y 60 s más tarde en la mediana.

Una primera espera cercana a la duración típica del run es el mejor compromiso con un runtime activo: cuatro llamadas en la mediana y resultados detectados en menos de 8 s. Cada llamada antes de que el run pueda haber terminado es una llamada desperdiciada.

El estado del propio runtime pesa más que la estrategia del cliente. En un escritorio que ha pasado a inactivo, el run mediano se queda en pending 152 s antes de que alguien lo reclame, y ningún intervalo de polling cambia eso. Si miras read_session_state y ves la tarea todavía pendiente tras unos minutos, es el backoff de inactividad del runtime, no un run atascado.

Los runs encolados uno tras otro en una misma sesión hacen cola:

  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

Si Codex encola tres goals y consulta los tres con un temporizador de 5 s, dos de ellos se pasan minutos devolviendo pending sin otro motivo que la cola.

¿Por qué una nota no puede lanzar un run?

post_session_note solo necesita osfoundry:write y un puesto de Contributor. Si una nota pudiera despertar a los agentes de la sesión para un run, todo Contributor sería un Runner con otro nombre. La descripción lo descarta: "Other agents can reply to the returned external sender slug; this does not trigger an automatic run."

Así que la nota es un mensaje, no un disparador. Llega al correo de la sesión con el nombre visible de la conexión. El cliente web muestra external_sender_name y una etiqueta "outside assistant" en Team mail. Los agentes pueden responderla la próxima vez que se ejecuten.

¿Qué devuelve el buzón?

read_session_mail se describe como "bounded notes addressed to this connection and notes it posted", con "Internal agent analysis is excluded." El esquema limita una página a limit de 1 a 20 y retrocede con before_id.

El script modela el correo de una sesión de equipo con mucho movimiento, 409 filas de los tipos de mensaje que conoce el cliente web (analysis, tool_call, tool_result, agent_note, broadcast), y aplica el filtro descrito:

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

Los recuentos de filas son del modelo. Lo que importa es la forma: lo que ve la conexión es una porción pequeña, así que limit: 20 suele traerlo todo en una o dos llamadas. El resultado del run tampoco está en esa porción. En el código del runtime de escritorio, el correo con el resultado se publica sin destinatario, así que no va dirigido a la conexión. Lee los resultados con read_session_task_result y la conversación con read_session_mail, y no esperes que uno sustituya al otro.

¿Dónde frenan los esquemas a un cliente?

Trece casos contra los inputSchema en vivo, validados con 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

Con dos es fácil tropezar. Un run no tiene campo context, así que el contexto va dentro de goal, y el límite de 10,000 caracteres cubre ambas cosas. Una nota no tiene to, así que no puedes dirigirla a un agente concreto. Toda nota va a la sesión, y los agentes responden al sender slug de la conexión.

El script completo tardó 19.3 s de tiempo real en el M1, incluidas 40 llamadas de red. El tiempo de CPU de usuario fue de 0.47 s, así que la simulación en sí es despreciable.

Si lo montas tú mismo

  • Publica "start work" como un scope OAuth propio y condiciona el rol tanto al grant como al token, para que ni quien llama ni un grant obsoleto puedan convertirse en runner por su cuenta.
  • Devuelve enseguida un id de tarea y dale a quien llama una lectura de estado barata. No hagas que una llamada a herramienta MCP espere a que termine un run de agente.
  • Expón el comportamiento real de recogida del runtime (intervalo de polling, un run por sesión) en la documentación o en el estado, para que los clientes hagan polling a un ritmo sensato en vez de adivinar.
  • Trata todo lo que venga de alguien externo como entrada no confiable, aunque tenga un puesto de runner.
  • Mantén separadas las notas y los runs: quien escribe notas no debería poder despertar un run, y la lectura de vuelta debería excluir el análisis interno de la sesión.

Cómo se encolan los runs por sesión está en la página de agent task queue, y las otras formas de arrancar una sesión están en agent triggers.

Top comments (0)