>> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "claim_linked_chat_task", "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-3916-4b13-937d-d78de6b3b053"}}} << {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"claim_id\": \"8ffc774f-0a76-4846-9bbd-d66493ca08f4\", \"lease_until\": 1793942663.8222258}"}]}} >> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "submit_linked_chat_result", "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-…", "claim_id": "8ffc774f-…", "status": "completed", "result": "1. Who signs off the pricing copy?"}}} << {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true}"}]}} >> (same submit again, after a "lost" response) << {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true, \"replayed\": true}"}]}} 

Eso es un claim, un submit y un submit reintentado, capturados de una reconstrucción en Python (un servidor sqlite de 180 líneas) del traspaso que usa osFoundry para enviar trabajo a una conversación vinculada de Claude o ChatGPT. Los nombres de las herramientas son los del conector real. El servidor, el campo de lease y el flag de replay son de la reconstrucción. No se usó ningún binario ni cuenta de Claude: el "chat" del otro lado es un script de Python que hace las mismas llamadas JSON-RPC que haría un host, contra un fichero sqlite local.

La forma del problema es poco habitual. El worker es una conversación de chat que solo se ejecuta cuando una persona la abre y escribe. El servidor no puede despertarla, no puede verla y no puede saber si la persona cerró la pestaña a mitad de una tarea.

¿Qué expone realmente osFoundry?

Cuatro herramientas del conector MCP de osFoundry llevan el traspaso, y sus esquemas dicen casi todo lo que importa:

Herramienta Argumentos obligatorios Qué promete su descripción
link_current_chat project_id, label, idempotency_key Un vínculo distinto por conversación; la etiqueta y la referencia las declara el propio chat
read_linked_chat_tasks chat_link_id Tareas en cola y recientes, limitadas a la conexión OAuth y al proyecto
claim_linked_chat_task chat_link_id, task_id "Repeating the claim returns the same claim while the task remains active." (repetir el claim devuelve el mismo claim mientras la tarea siga activa)
submit_linked_chat_result chat_link_id, task_id, claim_id, status, result completed o failed, resultado de hasta 20,000 caracteres; los claims cancelados, expirados, revocados o que no coinciden no se pueden completar

El lado del propietario vive en la app web, en apps/web/src/screens/settings/ConnectedChatTasks.tsx. Su unión de estados es queued | claimed | completed | failed | cancelled | expired, con las etiquetas "Waiting for host chat" (o "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled" y "Expired". Al asignar, publica el objetivo con un idempotency_key generado con crypto.randomUUID(), y el componente conserva esa clave mientras el texto del objetivo no cambie, así que un doble clic o un reintento tras un timeout envía la misma clave. El panel refresca su lista con setInterval(..., 15000). Las tareas expiran a las 24 horas.

Una quinta herramienta, upload_linked_chat_artifact, recibe un artifact_id estable en los reintentos. La misma idea, aplicada a ficheros.

¿Por qué el servidor no puede empujar la tarea a Claude?

Porque en el lado del host no hay nada escuchando. osFoundry trata un conector personalizado de Claude como algo dirigido por peticiones: la conversación activa puede llamar herramientas, y no existe un disparador de eventos general que inicie un turno. ChatGPT tiene MCP Events para los chats de Work que se suscriben; el panel del propietario muestra "An event subscription is active for this chat" solo en ese caso, y si no, "Open the host conversation to start each task." (abre la conversación del host para iniciar cada tarea).

Así que el planificador es una persona. Eso cambia qué fallos son normales. Una tarea en cola que lleva seis horas esperando no es una caída. Una tarea reclamada cuyo chat se cerró es el caso común, no el caso límite. Y un reintento no es solo cosa de la red: el modelo puede simplemente llamar a la herramienta dos veces en un mismo turno.

¿Cómo ganan los dos claims sobre la misma tarea?

El handler obvio lee la fila, comprueba status == 'queued' y escribe claimed. La reconstrucción tiene esa versión detrás de --naive 1, y la versión condicional por defecto:

cur = c.execute("UPDATE tasks SET status='claimed', claim_id=?, lease_until=?, updated_at=? " "WHERE id=? AND chat_link_id=? AND status='queued'", (cid, now + self.lease, now, task_id, link)) if cur.rowcount == 1: return {"claim_id": cid, "lease_until": now + self.lease} 

Prueba: 200 tareas, y para cada una, ocho hilos liberados por un threading.Barrier llaman a claim_linked_chat_task en el mismo instante por HTTP.

Handler Pruebas con 2+ ganadores Media de ganadores por tarea
Leer y luego escribir (2 ms entre medias) 200 / 200 7.40
Leer y luego escribir (sin hueco añadido) 97 / 200 y 172 / 200 en dos ejecuciones 1.82 y 3.36
UPDATE … WHERE status='queued' condicional 0 / 200 1.00

El hueco de 2 ms representa un salto de red o un ORM entre las dos sentencias. Sin él, la carrera sigue produciéndose en aproximadamente la mitad de las pruebas o más, y la cifra varió con la carga de la máquina. El lock de escritura de SQLite serializa las dos escrituras, pero no ayuda: los dos lectores ya habían decidido.

Ocho claims simultáneos desde un mismo chat vinculado suena forzado. Dos no: el usuario le pide al chat que revise las tareas, se impacienta, lo vuelve a pedir, y el host ejecuta los dos turnos. Por eso también el claim de osFoundry devuelve el claim existente al mismo vínculo en lugar de un error. La reconstrucción hace lo mismo ante una repetición desde el mismo chat:

"claim_replay": {"first": "28a03379", "repeat_same_chat": "28a03379", "same_claim": true, "other_link": {"error": "not claimable: missing"}} 

Un vínculo distinto que pida el mismo ID de tarea no obtiene nada, ni siquiera la confirmación de que la tarea existe.

¿Qué debería pasar cuando el chat que reclamó la tarea desaparece?

Aquí la reconstrucción va más allá de lo que la documentación de osFoundry dice sobre los chats vinculados. La documentación indica una expiración de tareas de 24 horas y, para el dispatcher de escritorio que permite a un agente de osFoundry esperar a un chat vinculado, un límite de ejecución de 30 minutos. La reconstrucción añade un lease por claim: un claim que no se completa antes de lease_until vuelve a queued y se puede reclamar de nuevo.

La expiración es perezosa. No hay hilo con temporizador; cada lectura, claim y submit ejecuta antes un barrido:

c.execute("UPDATE tasks SET status='queued', claim_id=NULL, lease_until=NULL, updated_at=? " "WHERE status='claimed' AND lease_until<=?", (now, now)) 

Prueba con un lease de 1 segundo: el chat A reclama y se queda en silencio. El chat B intenta reclamar cada 100 ms.

"lease": {"lease_s": 1.0, "takeover_after_s": 1.064, "failed_claims_before_takeover": 10, "late_submit_A": {"error": "rejected: stale claim, task was re-claimed"}, "submit_B": {"ok": true}} 

B tomó el control 64 ms después de que venciera el lease, es decir, un intervalo de sondeo más el tiempo de la petición. La línea importante es la tercera: cuando A volvió e hizo submit con su claim_id antiguo, el servidor lo rechazó. El ID del claim es un fencing token. Sin él en la cláusula WHERE, la respuesta tardía de A sobrescribiría la de B, y el propietario vería la que terminara la última.

Elegir la duración del lease es la parte difícil con un worker que depende de una persona. Si es demasiado corto, un turno de Claude lento pero vivo pierde su tarea a mitad de respuesta. Si es demasiado largo, una pestaña cerrada retiene trabajo durante horas. Un lease solo vale la pena si hay otro que pueda recoger la tarea; con exactamente un chat vinculado por tarea, la expiración de 24 horas más un botón "Cancel" para el propietario puede ser el diseño honesto.

¿Qué pasa cuando se pierde la respuesta de un submit?

El host envía el resultado, el servidor hace commit, la respuesta muere por el camino y el host reintenta. La prueba envía el mismo submit cuatro veces y luego un quinto con un resultado distinto:

Handler Respuestas a 4 envíos idénticos Respuesta a un resultado cambiado Resultado guardado
Añade cada llamada (como publicar un mensaje) ok, ok, ok, ok ok 4\n4\n4\n4\n5
Con fencing por claim, comparando el hash del resultado ok, luego 3 × ok + replayed rejected: task is completed 4

La versión idempotente guarda sha256(status + result) en la fila. Un reintento con el mismo claim y el mismo hash recibe éxito; cualquier otra cosa contra una tarea terminada se rechaza. osFoundry aplica la misma regla a las finalizaciones en su relay de Graph: una finalización idéntica tras una respuesta perdida se acepta otra vez, una distinta se rechaza. El reintento tiene que devolver éxito, no un error. Un error empujaría al modelo a "arreglar" un resultado que ya estaba guardado.

¿Se puede completar todavía una tarea cancelada?

No, si cancelar es un cambio de estado y el submit exige status='claimed':

"cancel": {"cancel": {"cancelled": true}, "submit_after_cancel": {"error": "rejected: task is cancelled"}} 

Lo interesante es lo que ve el chat. El turno de Claude hizo el trabajo; la respuesta rebotó. La descripción de la herramienta de osFoundry le dice al modelo de antemano que los claims cancelados no se pueden completar, que es la forma más barata de evitar que reintente en bucle.

¿Cuánto cuesta el sondeo?

Hay dos sondeadores distintos. El panel del propietario en el navegador sondea cada 15 segundos, lo que son 5,760 peticiones al día por cada panel abierto. El chat del host no sondea en absoluto; lee una vez cada vez que se lo pides. Los dos usan la misma consulta de lectura.

Medido en la reconstrucción con 10,000 tareas históricas repartidas en 500 vínculos, 2,000 lecturas de un vínculo, HTTP en localhost:

Petición Respuesta Ida y vuelta p50 Servidor p50
Bandeja vacía 137 B 87 B 0.72 ms 0.26 ms
Una tarea en cola 137 B 294 B 0.89 ms 0.30 ms

El coste no es el ancho de banda. La primera versión era unas nueve veces más lenta en el servidor, y la causa era el barrido perezoso: dos UPDATE en cada lectura, que toman el lock de escritura de SQLite incluso cuando no coinciden con nada. Tres ejecuciones de 2,000 sondeos vacíos cada una:

Barrido antes de cada lectura Servidor p50 (3 ejecuciones)
Dos UPDATE, escaneo de tabla 2.17 / 2.02 / 1.96 ms
Dos UPDATE, índice parcial sobre filas activas 1.33 / 1.33 / 1.20 ms
Primero SELECT 1 … LIMIT 1 sobre el índice parcial, UPDATE solo si hay algo vencido 0.24 / 0.21 / 0.22 ms
Sin barrido 0.19 / 0.19 / 0.22 ms

Una guarda de solo lectura deja el sondeo dentro del ruido respecto a no barrer nada. El índice por sí solo no lo consigue, porque el coste es la transacción de escritura, no el escaneo.

Para un cliente scriptado que sí sondea, el compromiso es el de siempre. 40 tareas que llegan con 0.2 a 1.2 s de separación:

Intervalo de sondeo Retraso medio de recogida Máx. Sondeos Sondeos vacíos
0.1 s 0.058 s 0.107 s 244 204
0.5 s 0.226 s 0.484 s 54 17
2.0 s 1.103 s 2.006 s 15 2

La recogida media cae cerca de la mitad del intervalo. Para un chat de host, el intervalo real es "cuando el usuario lo abre", y el coste por lectura que importa es el contexto, no los milisegundos: cada resultado de lectura entra en la conversación. Con 87 bytes para una bandeja vacía y unos 210 más por cada tarea en cola en este formato, una lista de tareas escueta mantiene la comprobación lo bastante barata como para pedirla sin pensarlo.

Todas las cifras: Apple M1, Python 3.9.6, SQLite 3.43.2 en modo WAL, ThreadingHTTPServer en 127.0.0.1, con otros trabajos de render corriendo en la máquina (load average entre 8 y 22 durante las ejecuciones), así que fíate de los p50 y trata las colas como ruidosas.

Si lo montas tú mismo

  • Haz que el claim sea una única escritura condicional y devuelve el claim existente al mismo llamante; el doble clic y la doble llamada a la herramienta son el caso normal.
  • Pon el ID del claim en la cláusula WHERE del submit para que un worker tardío no pueda sobrescribir a uno más reciente.
  • Guarda un hash del resultado final y responde a un reintento idéntico con éxito y a uno cambiado con un rechazo.
  • Decide si un lease te aporta algo. Si solo un chat puede tomar la tarea, una expiración y un cancelar visible pueden ser todo lo que necesitas.
  • Mantén las comprobaciones de expiración en solo lectura hasta que haya algo realmente vencido; un sondeo que toma un lock de escritura cuesta más que la consulta que protege.
  • Di en la descripción de la herramienta qué se va a rechazar, para que el modelo no reintente contra una tarea cancelada.