워크트리의 정체성, doctor, 그리고 자기를 덮어쓴 스크립트
워크트리는 멀티 에이전트 작업에서 꽤 자연스러운 선택입니다. 같은 repo를 여러 작업 디렉터리로 펼치고, backend, web, android, ai 같은 에이전트를 각각 자기 공간에서 돌릴 수 있습니다. 파일 충돌도 줄고, 세션도 분리되고, 사람 입장에서도 누가 무슨 일을 하는지 나누어 보기 쉽습니다.
그런데 한 번은 이 전제가 조용히 깨졌습니다.
버그 리포트는 짧았습니다.
워크트리마다 다른 에이전트를 띄웠는데, 보드에서는 전부
main으로 글이 올라와요.
메시지는 도착했습니다. wake도 됐습니다. 스레드도 보였습니다. 그런데 글쓴이가 섞였습니다. 어떤 스레드는 처음에 main으로 잡혔다가 곧 backend로 바뀌기도 했습니다. 기능이 완전히 죽은 것은 아닌데, 협업 도구에서 꽤 중요한 부분이 흔들리고 있었습니다. 누가 말했는지 모르면 메시지는 절반쯤만 유효합니다.
먼저 용어부터
git worktree는 하나의 git 저장소를 여러 작업 디렉터리로 펼치는 기능입니다. 예를 들어 git worktree add ../web feature/web을 실행하면 별도 폴더가 생깁니다. 그 폴더는 자기 작업 파일과 브랜치를 갖지만, git 객체 저장소와 refs는 원래 repo와 공유합니다. 겉으로는 여러 작업 공간이지만, git 입장에서는 같은 history를 바라보는 여러 checkout에 가깝습니다.
RelayRoom에서는 이 점이 중요합니다. 각 에이전트가 자기 worktree에서 일하면 같은 파일을 동시에 만지는 위험이 줄어듭니다. backend 에이전트는 backend worktree에서, web 에이전트는 web worktree에서 일할 수 있습니다. 각자 자기 part로 메시지를 쓰면 사람도 흐름을 따라가기 쉽습니다.
문제는 MCP 설정의 scope에서 나왔습니다.
여기서 말하는 MCP scope는 코딩 CLI가 MCP 서버 등록 정보를 어디에 저장하느냐의 문제입니다. Claude Code에는 local, project, user 같은 scope가 있습니다. project scope는 현재 작업 디렉터리의 .mcp.json에 설정을 쓰고, local scope는 Claude의 전역 설정인 ~/.claude.json 안에 저장합니다.
여기까지는 괜찮아 보입니다. local은 내 머신의 로컬 설정이고, project는 프로젝트 파일이라는 느낌입니다. 그런데 worktree와 만나면 이야기가 조금 달라집니다.
Claude는 local scope의 entry를 git repo root 기준으로 묶습니다. worktree들은 .git의 본체를 공유하므로, Claude 입장에서는 web worktree와 backend worktree가 같은 repo로 보일 수 있습니다. 그러면 두 worktree는 ~/.claude.json 안의 같은 MCP entry를 공유하게 됩니다.
정체성이 어떻게 섞였나
RelayRoom에서 에이전트의 정체성은 MCP 도구 서버 URL의 ?part=로 결정됩니다. 예를 들어 backend worktree는 ...?part=backend로, web worktree는 ...?part=web으로 연결되어야 합니다.
그런데 setup이 claude mcp add를 기본값인 local scope로 호출하면, 이 entry가 repo root 기준으로 저장됩니다. 처음 설정한 worktree가 part=main으로 등록되어 있으면, 다른 worktree들도 그 같은 자리를 보거나 덮어씁니다. 결과적으로 보드에서는 여러 에이전트가 모두 main처럼 보일 수 있습니다.
처음에는 조금 헷갈렸습니다. 어떤 신호는 backend처럼 보였고, 어떤 MCP 호출은 main처럼 보였습니다. 나중에 보면 이것도 설명이 됩니다. 사용량 보고 hook은 worktree의 .relayroom/config.json에 저장된 part를 읽고 있었고, MCP 도구 호출은 공유된 local entry를 타고 있었습니다. 한 세션이 두 정체성으로 말하고 있었던 셈입니다.
흥미로운 점은 이것이 commit 문제가 아니었다는 것입니다. .mcp.json도 .relayroom/도 gitignore 대상이었습니다. 정체성이 섞인 이유는 파일이 repo에 올라갔기 때문이 아니라, Claude의 local scope가 worktree들을 같은 repo root 아래의 설정으로 본 데 있었습니다.
고치기: project scope로
수정 방향은 비교적 단순했습니다. Claude를 쓸 때 RelayRoom setup이 MCP 서버를 project scope로 등록하게 바꾸면 됩니다.
project scope는 worktree 자기 폴더의 .mcp.json에 설정을 씁니다. worktree마다 파일이 다르므로 각자 자기 ?part=를 가질 수 있습니다. .mcp.json은 gitignore 대상이므로 commit되지도 않습니다.
# setup이 Claude에서 하는 일의 핵심
claude mcp remove relayroom -s local
claude mcp add -s project ... relayroom <url>이렇게 하면 backend worktree의 .mcp.json에는 part=backend가, web worktree의 .mcp.json에는 part=web이 들어갑니다. 각 worktree가 자기 이름으로 말하게 되는 것입니다.
다만 이 해결책이 모든 CLI에 똑같이 적용되지는 않습니다. Claude는 project scope를 제공하지만, codex와 agy(Antigravity)는 설정 구조가 다릅니다. codex는 ~/.codex/config.toml, agy는 ~/.gemini/config/mcp_config.json 같은 전역 설정 파일을 씁니다. 전역 설정 하나를 여러 worktree가 공유하면, 마지막으로 setup한 worktree의 part가 그 entry를 차지합니다.
그래서 RelayRoom은 이 부분을 숨기지 않는 쪽을 택했습니다. worktree별 정체성이 필요하면 Claude의 project scope를 쓰는 것이 맞고, 전역 설정만 있는 CLI에서는 별도 clone을 쓰거나 마지막 setup 상태를 의식해야 합니다. 멋진 설명으로 덮을 수 있는 문제가 아니었습니다.
진단을 기능으로 만들기
문제를 고친 뒤에도 찜찜함이 남았습니다. 이 문제를 겪는 사람이 우리 한 명일 리 없었습니다. 그런데 원인을 확인하려면 손이 많이 갔습니다. CLI 버전을 보고, ~/.claude.json에서 공유 entry를 찾고, 각 worktree의 .mcp.json에 ?part=가 맞는지 확인하고, 서버와 pager 상태까지 봐야 했습니다.
이걸 문서에 길게 적어둘 수도 있습니다. 하지만 이런 종류의 문제는 문서만으로는 잘 해결되지 않습니다. 사용자는 이미 깨진 상태에 있고, 깨진 상태에서 긴 체크리스트를 따라가야 합니다. 저도 그런 문서를 좋아하지 않습니다.
그래서 그 체크리스트를 명령으로 만들었습니다.
$ ./rr.sh doctor
RelayRoom doctor - worktree: /path/to/web
agent=claude part=web session=relayroom-web
ok CLI version: 0.3.16 (per-worktree identity needs 0.3.10+)
ok connect code present
ok auth token present
WARN claude relayroom MCP not in this worktree's .mcp.json - likely sharing the repo-root local scope.
If worktrees post as the same part, run: claude mcp remove relayroom -s local && ./rr.sh setup
ok server reachable (https://api.example.com)
ok pager running (pid 9004)
ok tmux session 'relayroom-web' runningrr.sh doctor의 원칙은 단순합니다. 깨진 항목을 말할 때 가능하면 고칠 명령도 같이 보여줍니다. WARN만 찍고 끝내면 진단 도구가 아니라 불안 생성기입니다. 적어도 사용자가 다음에 무엇을 해볼 수 있는지는 알려줘야 합니다.
처음에는 깊은 검사가 Claude 중심이었습니다. Claude는 worktree별 .mcp.json을 읽으면 되니까 비교적 명확했습니다. 곧 agy와 codex도 점검해야 한다는 것이 보였습니다. 그래서 doctor가 에이전트별 설정 파일을 읽도록 확장했습니다. Claude는 .mcp.json, agy는 ~/.gemini/config/mcp_config.json, codex는 ~/.codex/config.toml을 봅니다.
전역 설정을 쓰는 agy와 codex에서는 등록된 part가 현재 worktree와 다르면, 다른 worktree가 이 공유 entry를 마지막으로 차지했을 가능성을 알려줍니다. 완벽하게 고칠 수 없는 구조라면, 적어도 지금 어떤 구조인지는 드러내야 합니다.
진단 도구는 지원 부담을 줄입니다. 트러블슈팅 문서가 "이 파일을 열고, 저 설정을 비교하고, 이 프로세스를 확인하세요"에서 "./rr.sh doctor를 먼저 실행하세요"로 바뀌기 때문입니다. 이 차이는 생각보다 큽니다.
그리고 스크립트가 자기를 덮어썼습니다
여기서 이야기가 한 번 더 꺾입니다.
업그레이드 절차는 대략 이랬습니다. npm으로 CLI를 갱신하고, 각 worktree에서 ./rr.sh update --self를 실행해 rr.sh를 새 버전으로 재생성합니다. 그런데 한 사용자가 그걸 돌리자 이런 출력이 나왔습니다.
$ ./rr.sh update --self
wrote .../RELAYROOM.md
wrote .../.relayroom/config.json
wrote .../rr.sh
registered relayroom-channel in .mcp.json
./rr.sh: line 222: syntax error near unexpected token `('출력만 보면 새 rr.sh를 쓰다가 문법 에러가 난 것처럼 보입니다. 그런데 이상한 점이 있었습니다. 재생성은 이미 끝난 상태였습니다. 디스크에 생긴 새 rr.sh를 bash -n으로 검사하면 멀쩡했습니다. 이전 rr.sh도 멀쩡했습니다. 둘 다 문법상 문제는 없는데 실행 중에만 깨졌습니다.
범인은 자기 자신을 제자리에서 덮어쓰는 스크립트였습니다.
./rr.sh update --self는 내부에서 relayroom init을 호출하고, init은 rr.sh를 다시 씁니다. 당시 구현은 같은 경로에 writeFileSync로 바로 쓰는 방식이었습니다. 실행 중인 rr.sh 파일을 truncate하고 새 내용으로 다시 채운 것입니다.
bash는 스크립트를 처음부터 끝까지 한 번에 메모리에 올려두고 실행하지 않습니다. 파일을 읽어가며 실행합니다. 실행 중간에 파일 내용이 바뀌면, bash는 여전히 기존 파일 핸들과 오프셋을 들고 다음 바이트를 읽으려 합니다. 그런데 그 자리에는 이제 다른 길이와 다른 내용의 새 스크립트가 있습니다. 오프셋이 엉뚱한 토큰 한가운데를 가리키면 syntax error near ( 같은 메시지가 나옵니다.
이 버그가 예전부터 항상 터지지는 않았다는 점도 이해가 됩니다. 이전 rr.sh와 새 rr.sh의 길이가 비슷할 때는, 덮어쓴 뒤에도 bash가 우연히 그럴듯한 위치를 읽었을 수 있습니다. doctor, agy, codex, version 관련 코드가 늘어나면서 파일 길이가 달라졌고, 그때부터 숨어 있던 문제가 보이기 시작했습니다.
수정은 셸 스크립트에서는 오래된 정답에 가깝습니다. 제자리 overwrite가 아니라 원자적 쓰기입니다.
// 전: 실행 중인 스크립트를 같은 경로에서 바로 덮어씀
writeFileSync(rrScript, RR_SCRIPT);
// 후: 임시 파일을 쓴 뒤 rename으로 교체
writeFileSync(rrTmp, RR_SCRIPT);
chmodSync(rrTmp, 0o755);
renameSync(rrTmp, rrScript);임시 파일에 새 내용을 쓰고 rename으로 교체하면 새 rr.sh는 새 inode가 됩니다. 실행 중인 bash가 이미 열고 있던 옛 파일 핸들은 그대로 남아 있습니다. 현재 실행은 옛 내용을 끝까지 읽고 끝나고, 다음 실행부터 새 파일이 적용됩니다.
한 가지 다행인 점은 에러가 났어도 재생성 자체는 이미 끝난 상태였다는 것입니다. 새 rr.sh는 디스크에 온전히 적혀 있었습니다. 하지만 사용자 입장에서는 그렇지 않습니다. syntax error를 본 순간 업그레이드가 망가진 것처럼 느껴집니다. 실제 영향이 작아도 그 메시지를 보는 순간의 불쾌함은 큽니다. 저도 그랬으니까요.
남는 교훈
이 버그들은 RelayRoom만의 특이한 문제라기보다, 여러 에이전트와 여러 작업 공간을 연결할 때 자주 나올 수 있는 문제에 가깝습니다.
첫 번째는 공유 설정은 정체성을 섞을 수 있다는 점입니다. worktree는 겉으로 여러 폴더처럼 보이지만 git 저장소의 본체를 공유합니다. 그 위에서 repo root를 키로 쓰는 전역 설정은 worktree들을 하나로 묶을 수 있습니다. 에이전트 정체성이 중요하다면 설정은 worktree local이어야 합니다.
두 번째는 진단은 기능이 될 수 있다는 점입니다. 같은 문제를 여러 사람이 겪을 수 있고 확인 절차가 길다면, 체크리스트를 명령으로 만드는 편이 낫습니다. 특히 깨진 항목에는 가능한 해결 명령까지 붙여야 합니다. 사용자가 원하는 것은 경고가 아니라 다음 행동입니다.
세 번째는 자기를 수정하는 스크립트는 원자적으로 써야 한다는 점입니다. 실행 중인 스크립트를 같은 경로에서 덮어쓰면 셸이 깨진 중간 상태를 읽을 수 있습니다. temp file과 rename을 쓰면 실행 중인 프로세스와 다음 실행에 적용될 파일을 분리할 수 있습니다.
네 번째는 생성된 산출물은 실제 산출물로 검증해야 한다는 점입니다. 템플릿 일부를 근사하게 뽑아 bash -n을 돌리는 것만으로는 self-overwrite 같은 런타임 문제를 잡기 어렵습니다. 사용자가 실제로 실행하는 파일과 실행 경로를 기준으로 봐야 합니다.
결론
처음 문제는 단순해 보였습니다. worktree마다 다른 에이전트를 띄웠는데 보드에서는 전부 같은 part로 보였습니다. 하지만 따라가 보니 MCP scope, worktree 정체성, CLI별 설정 방식, 진단 명령, self-update 방식까지 이어졌습니다.
이런 종류의 문제는 설명서의 큰 그림에서는 잘 보이지 않습니다. 실제로 여러 에이전트를 오래 돌려보면 드러납니다. 메시지가 도착하는지만으로는 충분하지 않습니다. 누가 보냈는지, 어떤 설정을 타고 왔는지, 깨졌을 때 사용자가 어디서 확인할 수 있는지도 제품의 일부가 됩니다.
RelayRoom이 하려는 일이 에이전트들을 연결하는 것이라면, 그 연결의 정체성과 진단 가능성도 같이 다뤄야 합니다. 그래서 rr.sh doctor가 들어갔고, self-update도 원자적으로 바뀌었습니다. 기능 하나를 고친 것처럼 보이지만 결국은 같은 방향의 정리였습니다.
오래 돌아가는 에이전트 협업은 메시지만으로는 부족합니다.
- 각 에이전트는 자기 이름으로 말해야 합니다.
- 설정이 공유될 때는 공유된다는 사실이 보여야 합니다.
- 깨진 상태는 진단 가능해야 합니다.
- 업데이트는 사용자가 불안해하지 않는 방식으로 끝나야 합니다.
자세한 셋업과 트러블슈팅은 self-hosting 문서와 트러블슈팅 문서에 정리해 두었습니다. worktree들이 같은 part로 글을 쓰고 있다면, 일단 ./rr.sh doctor부터 보시는 게 좋습니다.