DEV Community

Cover image for Bagaimana chat Claude mengklaim task osFoundry lewat MCP tanpa event push?
BBT for BBT Indonesia

Posted on

Bagaimana chat Claude mengklaim task osFoundry lewat MCP tanpa event push?

>> {"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}"}]}}
Enter fullscreen mode Exit fullscreen mode

Di atas ada satu claim, satu submit, dan satu submit yang diulang, diambil dari rebuild dalam Python (server sqlite 180 baris) atas mekanisme serah-terima yang dipakai osFoundry untuk mengirim pekerjaan ke percakapan Claude atau ChatGPT yang tertaut. Nama tool-nya sama dengan connector aslinya. Server, field lease, dan flag replay adalah bagian dari rebuild. Tidak ada binary Claude atau akun Claude yang dipakai: "chat" di sisi lain adalah skrip Python yang melakukan panggilan JSON-RPC yang sama seperti yang dilakukan host, ke sebuah file sqlite lokal.

Bentuk masalahnya tidak biasa. Worker-nya adalah percakapan chat yang hanya berjalan saat ada orang yang membukanya dan mengetik. Server tidak bisa membangunkannya, tidak bisa melihatnya, dan tidak bisa tahu apakah orang itu menutup tab di tengah pengerjaan task.

Apa yang sebenarnya diekspos osFoundry?

Serah-terima ini dibawa oleh empat tool di connector MCP osFoundry, dan schema-nya sudah menjelaskan sebagian besar hal penting:

Tool Argumen wajib Yang dijanjikan deskripsinya
link_current_chat project_id, label, idempotency_key Satu link terpisah per percakapan; label dan referensi dilaporkan sendiri oleh chat
read_linked_chat_tasks chat_link_id Task yang antre dan yang baru, dibatasi pada koneksi OAuth dan project
claim_linked_chat_task chat_link_id, task_id "Repeating the claim returns the same claim while the task remains active."
submit_linked_chat_result chat_link_id, task_id, claim_id, status, result completed atau failed, hasil sampai 20,000 karakter; claim yang dibatalkan, kedaluwarsa, dicabut, atau tidak cocok tidak bisa diselesaikan

Sisi pemilik ada di web app, di apps/web/src/screens/settings/ConnectedChatTasks.tsx. Union status-nya adalah queued | claimed | completed | failed | cancelled | expired, dengan label "Waiting for host chat" (atau "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled", dan "Expired". Saat kamu meng-assign task, goal dikirim dengan idempotency_key dari crypto.randomUUID(), dan komponennya menyimpan key itu selama teks goal tidak berubah, jadi double-click atau retry setelah timeout mengirim key yang sama. Panel me-refresh daftarnya dengan setInterval(..., 15000). Task kedaluwarsa setelah 24 jam.

Tool kelima, upload_linked_chat_artifact, menerima artifact_id yang stabil saat retry. Idenya sama, hanya diterapkan ke file.

Kenapa server tidak bisa mem-push task ke Claude?

Karena di sisi host tidak ada yang mendengarkan. osFoundry memperlakukan custom connector Claude sebagai berbasis request: percakapan yang aktif bisa memanggil tool, dan tidak ada trigger event umum yang bisa memulai sebuah turn. ChatGPT punya MCP Events untuk chat Work yang berlangganan; panel pemilik menampilkan "An event subscription is active for this chat" hanya dalam kasus itu, dan selain itu "Open the host conversation to start each task."

Jadi scheduler-nya adalah manusia. Ini mengubah kegagalan mana yang dianggap normal. Task yang antre selama enam jam bukan outage. Task yang sudah di-claim tapi chat-nya ditutup adalah kasus umum, bukan edge case. Dan retry bukan cuma urusan jaringan: model bisa saja memanggil tool dua kali dalam satu turn.

Bagaimana dua claim pada task yang sama bisa sama-sama menang?

Handler yang paling jelas membaca baris, mengecek status == 'queued', lalu menulis claimed. Rebuild-nya punya versi itu di balik --naive 1, dan versi kondisional sebagai default:

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}
Enter fullscreen mode Exit fullscreen mode

Tes: 200 task, dan untuk masing-masing, delapan thread yang dilepas oleh threading.Barrier memanggil claim_linked_chat_task pada saat yang sama lewat HTTP.

Handler Percobaan dengan 2+ pemenang Rata-rata pemenang per task
Baca, lalu tulis (jeda 2 ms di antaranya) 200 / 200 7.40
Baca, lalu tulis (tanpa jeda tambahan) 97 / 200 dan 172 / 200 dalam dua run 1.82 dan 3.36
UPDATE … WHERE status='queued' kondisional 0 / 200 1.00

Jeda 2 ms itu mewakili satu hop jaringan atau ORM di antara dua statement. Tanpa jeda pun race-nya tetap terjadi di sekitar separuh percobaan atau lebih, dan angkanya berubah mengikuti beban mesin. Write lock SQLite memang menserialkan kedua penulisan, tapi tidak membantu: kedua pembaca sudah mengambil keputusan.

Delapan claim bersamaan dari satu chat tertaut terdengar dibuat-buat. Dua tidak: user meminta chat mengecek task, tidak sabar, meminta lagi, dan host menjalankan kedua turn. Itu juga alasan claim di osFoundry mengembalikan claim yang sudah ada ke link yang sama, bukan error. Rebuild-nya melakukan hal yang sama untuk pengulangan dari chat yang sama:

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

Link lain yang meminta task ID yang sama tidak mendapat apa-apa, bahkan tidak ada konfirmasi bahwa task itu ada.

Apa yang harus terjadi kalau chat yang meng-claim task menghilang?

Di sini rebuild-nya melangkah lebih jauh dari apa pun yang dinyatakan dokumentasi osFoundry untuk chat tertaut. Dokumentasinya menyebut masa kedaluwarsa task 24 jam dan, untuk dispatcher desktop yang memungkinkan agent osFoundry menunggu chat tertaut, batas runtime 30 menit. Rebuild-nya menambahkan lease per claim: claim yang belum selesai sampai lease_until kembali ke queued dan bisa di-claim lagi.

Kedaluwarsanya lazy. Tidak ada thread timer; setiap read, claim, dan submit menjalankan sweep lebih dulu:

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

Tes dengan lease 1 detik: chat A meng-claim lalu diam. Chat B mencoba meng-claim setiap 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}}
Enter fullscreen mode Exit fullscreen mode

B mengambil alih 64 ms setelah lease habis, yaitu satu interval polling ditambah waktu request. Baris yang lebih penting adalah baris ketiga: saat A kembali dan melakukan submit dengan claim_id lamanya, server menolaknya. Claim ID adalah fencing token. Tanpa claim ID di klausa WHERE, jawaban A yang terlambat akan menimpa jawaban B, dan pemilik akan melihat mana pun yang selesai paling akhir.

Memilih durasi lease adalah bagian tersulit kalau worker-nya digerakkan manusia. Terlalu pendek, turn Claude yang lambat tapi masih hidup kehilangan task-nya di tengah jawaban. Terlalu panjang, tab yang sudah ditutup menahan pekerjaan berjam-jam. Lease hanya ada gunanya kalau ada pihak lain yang bisa mengambil task itu; dengan tepat satu chat tertaut per task, kedaluwarsa 24 jam ditambah tombol "Cancel" untuk pemilik mungkin desain yang paling jujur.

Apa yang terjadi kalau response submit hilang?

Host mengirim hasil, server melakukan commit, response-nya hilang di jalan pulang, host mencoba lagi. Tes ini mengirim submit yang sama empat kali, lalu yang kelima dengan hasil berbeda:

Handler Balasan untuk 4 kiriman identik Balasan untuk hasil yang diubah Hasil yang tersimpan
Menambahkan setiap panggilan (seperti mem-posting pesan) ok, ok, ok, ok ok 4\n4\n4\n4\n5
Dipagari claim, hash hasil dibandingkan ok, lalu 3 × ok + replayed rejected: task is completed 4

Versi yang idempoten menyimpan sha256(status + result) di baris. Retry dengan claim yang sama dan hash yang sama mendapat sukses; apa pun selain itu terhadap task yang sudah terminal ditolak. osFoundry menerapkan aturan yang sama untuk completion di relay Graph-nya: completion identik setelah response yang hilang diterima lagi, yang berubah ditolak. Retry harus mengembalikan sukses, bukan error. Error akan mendorong model untuk "memperbaiki" hasil yang sebenarnya sudah tersimpan.

Apakah task yang dibatalkan masih bisa diselesaikan?

Tidak, kalau cancel adalah perubahan state dan submit mensyaratkan status='claimed':

"cancel": {"cancel": {"cancelled": true}, "submit_after_cancel": {"error": "rejected: task is cancelled"}}
Enter fullscreen mode Exit fullscreen mode

Bagian yang menarik adalah apa yang dilihat chat. Turn Claude sudah mengerjakan tugasnya; jawabannya ditolak. Deskripsi tool osFoundry memberi tahu model sejak awal bahwa claim yang dibatalkan tidak bisa diselesaikan, dan itu cara paling murah untuk mencegahnya retry berulang-ulang dalam loop.

Berapa biaya polling?

Ada dua poller yang berbeda. Panel pemilik di browser melakukan polling setiap 15 detik, yaitu 5,760 request per hari untuk setiap panel yang terbuka. Chat host sama sekali tidak polling; ia membaca sekali setiap kali kamu memintanya. Keduanya memakai query baca yang sama.

Diukur pada rebuild dengan 10,000 task historis yang tersebar di 500 link, 2,000 pembacaan untuk satu link, HTTP localhost:

Request Response Round trip p50 Server p50
Inbox kosong 137 B 87 B 0.72 ms 0.26 ms
Satu task antre 137 B 294 B 0.89 ms 0.30 ms

Biayanya bukan di bandwidth. Versi pertama sekitar sembilan kali lebih lambat di server, dan penyebabnya adalah sweep yang lazy: dua UPDATE di setiap pembacaan, yang mengambil write lock SQLite bahkan saat tidak ada baris yang cocok. Tiga run, masing-masing 2,000 poll kosong:

Sweep sebelum setiap pembacaan Server p50 (3 run)
Dua UPDATE, table scan 2.17 / 2.02 / 1.96 ms
Dua UPDATE, partial index pada baris aktif 1.33 / 1.33 / 1.20 ms
SELECT 1 … LIMIT 1 pada partial index dulu, UPDATE hanya kalau ada yang jatuh tempo 0.24 / 0.21 / 0.22 ms
Tanpa sweep 0.19 / 0.19 / 0.22 ms

Guard yang read-only membuat satu poll hampir tidak bisa dibedakan dari tanpa sweep sama sekali. Index saja tidak cukup, karena biayanya ada di transaksi tulis, bukan di scan.

Untuk client berbasis skrip yang memang polling, trade-off-nya yang biasa. 40 task yang datang dengan jarak 0.2 sampai 1.2 s:

Interval polling Rata-rata delay pengambilan Maks Poll Poll kosong
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

Rata-rata delay pengambilan jatuh di sekitar setengah interval. Untuk chat host, interval sebenarnya adalah "kapan pun user membukanya", dan biaya per pembacaan yang penting adalah context, bukan milidetik: setiap hasil pembacaan masuk ke percakapan. Dengan 87 byte untuk inbox kosong dan sekitar 210 byte tambahan per task antre dalam format ini, daftar task yang ringkas membuat pengecekan cukup murah untuk diminta kapan saja.

Semua angka: Apple M1, Python 3.9.6, SQLite 3.43.2 dalam mode WAL, ThreadingHTTPServer di 127.0.0.1, dengan job render lain berjalan di mesin yang sama (load average antara 8 dan 22 selama run), jadi baca p50-nya dan anggap tail-nya noisy.

Kalau kamu mau merangkainya sendiri

  • Jadikan claim satu penulisan kondisional dan kembalikan claim yang sudah ada ke pemanggil yang sama; double-click dan panggilan tool ganda adalah kasus normal.
  • Taruh claim ID di klausa WHERE pada submit supaya worker yang terlambat tidak bisa menimpa yang lebih baru.
  • Simpan hash dari hasil terminal, lalu jawab retry yang identik dengan sukses dan yang berubah dengan penolakan.
  • Tentukan apakah lease memang memberi manfaat. Kalau hanya satu chat yang bisa mengambil task, kedaluwarsa dan cancel yang terlihat mungkin sudah cukup.
  • Buat pengecekan kedaluwarsa tetap read-only sampai memang ada yang jatuh tempo; poll yang mengambil write lock lebih mahal daripada query yang dijaganya.
  • Tulis di deskripsi tool apa saja yang akan ditolak, supaya model tidak retry terhadap task yang sudah dibatalkan.

Top comments (0)