DEV Community

Cover image for 푸시 없이 Claude 채팅이 MCP로 osFoundry 작업을 가져가는 방법
BBT for BBT 한국어

Posted on

푸시 없이 Claude 채팅이 MCP로 osFoundry 작업을 가져가는 방법

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

>> (응답이 유실됐다고 치고 같은 submit 재전송)
<< {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true, \"replayed\": true}"}]}}
Enter fullscreen mode Exit fullscreen mode

claim 한 번, 결과 제출 한 번, 그리고 그 재전송이다. osFoundry가 연결된 Claude(또는 ChatGPT) 대화에 작업을 넘기는 구조를 Python이랑 sqlite로 다시 짜 봤고(서버 180줄), 위 로그는 거기서 뽑았다. 툴 이름은 실제 커넥터 그대로다. 서버 구현, lease_until, replayed는 재현판에서 붙인 거다. Claude 바이너리나 계정은 안 썼다. 반대편 "채팅"은 호스트가 보내는 것과 같은 JSON-RPC를 쏘는 Python 스크립트고, 로컬 sqlite 파일에 붙여서 돌렸다.

이 문제는 모양이 좀 특이하다. 워커가 채팅 대화라서 사람이 열고 뭔가 입력해야만 움직인다. 서버 쪽에서는 깨울 수도 없고, 볼 수도 없고, 작업 도중에 탭을 닫았는지도 알 수 없다.

osFoundry는 실제로 뭘 열어 두고 있나

작업 전달은 osFoundry MCP 커넥터의 툴 4개가 맡는다. 스키마만 봐도 핵심은 거의 다 나온다.

툴 필수 인자 설명에 적힌 약속
link_current_chat project_id, label, idempotency_key 대화마다 별도 링크. 라벨과 참조는 자기 신고
read_linked_chat_tasks chat_link_id 대기 중·최근 작업. OAuth 연결과 프로젝트 범위로 제한
claim_linked_chat_task chat_link_id, task_id 작업이 살아 있는 동안 claim을 반복하면 같은 claim이 돌아온다
submit_linked_chat_result chat_link_id, task_id, claim_id, status, result completed 또는 failed, 결과는 20,000자까지. 취소·만료·철회·불일치 claim으로는 완료 못 함

오너 쪽 화면은 웹 앱의 apps/web/src/screens/settings/ConnectedChatTasks.tsx에 있다. 상태는 queued | claimed | completed | failed | cancelled | expired 여섯 개고, 화면에는 "Waiting for host chat"(Claude면 "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled", "Expired"로 뜬다. 작업을 배정할 때는 crypto.randomUUID()로 만든 idempotency_key를 붙여서 POST하고, 작업 문구가 그대로면 컴포넌트가 같은 키를 계속 쓴다. 더블클릭을 하든 타임아웃 뒤에 다시 누르든 같은 키가 날아간다. 목록 갱신은 setInterval(..., 15000), 즉 15초 폴링이다. 작업은 24시간 뒤 만료된다.

다섯 번째 툴 upload_linked_chat_artifact는 재시도 때 같은 artifact_id를 쓰라고 돼 있다. 같은 발상을 파일에 적용한 거다.

왜 서버가 Claude한테 작업을 밀어 넣을 수 없나

호스트 쪽에서 듣고 있는 게 없어서다. osFoundry는 Claude 커스텀 커넥터를 요청 기반으로 다룬다. 열려 있는 대화는 툴을 호출할 수 있지만, 턴을 시작시키는 범용 이벤트 트리거는 없다. ChatGPT에는 Work 채팅용 MCP Events가 있어서, 구독한 경우에만 오너 화면에 "An event subscription is active for this chat"가 뜬다. 나머지는 "Open the host conversation to start each task."다.

결국 스케줄러가 사람이다. 그러면 "평범한 실패"의 목록이 바뀐다. 6시간 동안 대기 중인 작업은 장애가 아니다. claim한 채팅이 닫힌 채로 있는 건 엣지 케이스가 아니라 일상이다. 재시도도 네트워크만의 문제가 아니라, 모델이 한 턴 안에서 같은 툴을 두 번 부르는 일이 그냥 생긴다.

같은 작업에 claim 두 개가 다 통과하는 이유

제일 단순하게 짜면 행을 읽고 status == 'queued'인지 보고 claimed로 바꾼다. 재현판에서는 이걸 --naive 1로 빼 두고, 기본값은 조건부 UPDATE로 했다.

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

측정은 이렇게 했다. 작업 200개를 만들고, 작업마다 스레드 8개를 threading.Barrier로 맞춰서 동시에 HTTP로 claim_linked_chat_task를 쐈다.

핸들러 승자가 2개 이상 나온 시도 작업당 평균 승자 수
읽고 나서 쓰기(사이에 2 ms) 200 / 200 7.40
읽고 나서 쓰기(간격 없음) 두 번 돌려서 97 / 200, 172 / 200 1.82, 3.36
조건부 UPDATE … WHERE status='queued' 0 / 200 1.00

2 ms는 두 문장 사이에 끼는 네트워크 왕복이나 ORM을 흉내 낸 거다. 간격을 안 넣어도 절반 안팎 이상의 시도에서 레이스가 났고, 숫자는 머신 부하에 따라 흔들렸다. SQLite 쓰기 락이 두 쓰기를 줄 세우긴 하지만 소용없다. 두 리더 모두 이미 "비어 있다"고 판단을 끝낸 뒤라서다.

연결된 채팅 하나에서 동시에 8번은 억지 같다. 근데 2번은 흔하다. 작업 확인해 달라고 했다가 못 기다리고 한 번 더 말하면 호스트가 두 턴을 다 돌린다. osFoundry의 claim이 같은 링크의 재요청에 에러 대신 기존 claim을 돌려주는 것도 그래서일 거다. 재현판도 같은 링크의 재요청에는 같은 claim을 준다.

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

다른 링크가 같은 작업 ID를 달라고 하면 아무것도 안 준다. 작업이 있는지조차 안 알려 준다.

claim한 채팅이 사라지면 어떻게 하나

여기는 재현판이 osFoundry 문서보다 한 발 더 나간 부분이다. 연결된 채팅에 대해 osFoundry가 밝힌 건 작업의 24시간 만료, 그리고 osFoundry 에이전트가 연결된 채팅을 기다리는 데스크톱 디스패처의 30분 실행 제한이다. 재현판은 여기에 claim 단위 리스를 붙였다. lease_until까지 완료 안 된 claim은 queued로 돌아가고 다시 claim할 수 있다.

만료는 지연 평가로 처리했다. 타이머 스레드 없이 read·claim·submit마다 먼저 스윕을 돌린다.

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

리스 1초로 돌려 봤다. 채팅 A가 claim하고 조용해진다. 채팅 B는 100 ms마다 claim을 시도한다.

"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는 리스가 끝나고 64 ms 뒤에 넘겨받았다. 폴링 간격 한 번에 요청 시간을 더한 정도다. 중요한 건 세 번째 줄이다. 돌아온 A가 옛 claim_id로 제출하자 서버가 거절했다. claim ID가 펜싱 토큰 노릇을 하는 거다. WHERE 절에 이게 없으면 A의 늦은 답이 B의 답을 덮어쓰고, 오너는 나중에 끝난 쪽을 보게 된다.

사람이 돌리는 워커 상대로는 리스 길이가 제일 큰 삽질 포인트다. 너무 짧으면 느릴 뿐 살아 있는 Claude 턴이 답하는 도중에 작업을 뺏긴다. 너무 길면 닫힌 탭이 몇 시간씩 작업을 붙잡고 있는다. 애초에 리스는 다른 누군가가 주워 갈 수 있을 때만 의미가 있다. 작업 하나를 가져갈 수 있는 연결된 채팅이 하나뿐이라면, 24시간 만료랑 오너의 "Cancel" 버튼이 더 솔직한 설계일 수 있다.

제출 응답이 유실되면 어떻게 되나

호스트가 결과를 보내고, 서버가 커밋하고, 응답이 돌아오다 사라지고, 호스트가 재전송한다. 같은 submit을 네 번 보내고, 다섯 번째만 결과를 바꿨다.

핸들러 같은 내용 4번에 대한 응답 바꾼 결과에 대한 응답 저장된 결과
호출마다 이어 붙이기(메시지 게시와 같은 동작) ok, ok, ok, ok ok 4\n4\n4\n4\n5
claim으로 막고 결과 해시 비교 ok, 이어서 3번 ok + replayed rejected: task is completed 4

멱등 버전은 행에 sha256(status + result)를 저장한다. 같은 claim에 같은 해시로 재전송하면 성공을 주고, 끝난 작업에 그 밖의 요청이 오면 거절한다. osFoundry도 Graph 릴레이 완료에서 같은 규칙을 쓴다. 응답이 유실된 뒤 같은 완료가 다시 오면 받아 주고, 내용이 다르면 거절한다. 재전송에는 에러가 아니라 성공을 돌려줘야 한다. 에러를 주면 모델이 이미 저장된 결과를 "고치려고" 든다.

취소된 작업이 완료 처리될 수는 없나

취소를 상태 전이로 다루고 submit이 status='claimed'를 요구하면 안 된다.

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

재밌는 건 채팅 쪽에서 보이는 모습이다. Claude 턴은 작업을 끝냈는데 답이 튕겨 나온다. osFoundry 툴 설명은 취소된 claim으로는 완료할 수 없다고 모델에게 미리 알려 둔다. 재전송 루프를 막는 가장 싼 방법이다.

폴링 비용은 얼마나 되나

폴링하는 주체는 둘이다. 오너 브라우저 화면은 15초마다 읽으러 온다. 열린 화면 하나당 하루 5,760번이다. 호스트 채팅은 폴링을 안 한다. 부탁할 때마다 한 번 읽을 뿐이다. 둘 다 같은 조회 쿼리를 친다.

재현판에 과거 작업 10,000개(링크 500개분)를 넣고 링크 하나를 2,000번 읽었다. localhost HTTP다.

요청 응답 왕복 p50 서버 p50
빈 수신함 137 B 87 B 0.72 ms 0.26 ms
대기 작업 1개 137 B 294 B 0.89 ms 0.30 ms

대역폭은 비용이 아니다. 첫 버전은 서버에서 약 9배 느렸는데, 원인은 지연 평가 스윕이었다. 읽을 때마다 UPDATE를 두 번 날렸고, 매칭되는 행이 없어도 SQLite 쓰기 락을 잡는다. 빈 수신함 2,000번 읽기를 세 번씩 돌렸다.

읽기 전 스윕 서버 p50(3회)
UPDATE 2번, 테이블 스캔 2.17 / 2.02 / 1.96 ms
UPDATE 2번, 활성 행만 부분 인덱스 1.33 / 1.33 / 1.20 ms
부분 인덱스로 먼저 SELECT 1 … LIMIT 1, 만료 대상이 있을 때만 UPDATE 0.24 / 0.21 / 0.22 ms
스윕 없음 0.19 / 0.19 / 0.22 ms

읽기 전용 가드를 하나 끼우면 스윕 없는 것과 오차 범위까지 줄어든다. 인덱스만으로는 안 된다. 비용은 스캔이 아니라 쓰기 트랜잭션 자체라서다.

실제로 폴링하는 스크립트 클라이언트라면 익숙한 트레이드오프다. 작업 40개를 0.2~1.2초 간격으로 넣었다.

폴링 간격 평균 픽업 지연 최대 폴링 횟수 헛폴링
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

평균 픽업은 간격의 절반 근처에 떨어진다. 호스트 채팅의 진짜 간격은 "사용자가 열 때"고, 한 번 읽을 때 문제가 되는 건 밀리초가 아니라 컨텍스트다. 읽은 결과가 전부 대화에 들어간다. 이 포맷이면 빈 수신함이 87바이트, 대기 작업 하나당 약 210바이트씩 늘어난다. 목록을 짧게 유지하면 부담 없이 "확인해 줘"라고 할 수 있는 수준이다.

측정 환경은 Apple M1, Python 3.9.6, SQLite 3.43.2(WAL 모드), 127.0.0.1의 ThreadingHTTPServer. 같은 머신에서 다른 렌더 작업이 돌고 있었고 측정 중 로드 애버리지는 8~22였다. p50 위주로 보고 꼬리 값은 노이즈가 많다고 생각하면 된다.

직접 붙인다면 정해 둘 것

  • claim은 조건부 쓰기 한 번으로 끝내고, 같은 호출자에게는 기존 claim을 돌려준다. 더블클릭이나 툴 이중 호출은 평소 일이다.
  • submit의 WHERE 절에 claim ID를 넣어서 늦게 온 워커가 새 쪽을 덮어쓰지 못하게 한다.
  • 종료 결과의 해시를 저장하고, 같은 재전송엔 성공, 내용이 다른 재전송엔 거절을 준다.
  • 리스가 정말 뭔가를 사 주는지 따져 본다. 작업을 가져갈 채팅이 하나뿐이면 만료와 눈에 보이는 취소로 충분할 수 있다.
  • 만료 체크는 대상이 생길 때까지 읽기 전용으로 둔다. 쓰기 락을 잡는 폴링은 그게 지키는 쿼리보다 비싸다.
  • 무엇이 거절되는지 툴 설명에 써 둔다. 취소된 작업에 모델이 계속 재전송하는 걸 막을 수 있다.

Top comments (0)