문서가 "거부한다"고 적어둔 herdr 메서드가 한 일
RelayRoom 0.8.0은 tmux 옆에 herdr를 멀티플렉서로 추가합니다. 기능 목록은 이 글에서 흥미로운 부분이 아닙니다. 흥미로운 것은 이 릴리스에서 서술을 믿었다가 틀린 것이 세 번이고, 셋 다 틀렸다는 점입니다.
서로 다른 세 사건이 아닙니다. 코드로부터의 거리만 다른 같은 실패입니다. 남의 문서, 남의 상태 필드, 그리고 우리 자신의 릴리스 노트입니다.
가장 먼 것: 바로 이 일을 하라고 있는 메서드
herdr에는 agent.prompt가 있습니다. 페이저가 하는 일, 즉 에이전트에게 프롬프트를 건네는 바로 그 용도로 존재하는 메서드입니다. 에이전트가 blocked 상태면 거부한다고 문서화돼 있습니다.
herdr 0.8.0에서, 사람이 손대지 않은 pane에서, Claude Code가 "Is this a project you trust?"에 멈춰 있고 herdr 자신도 agent_status: blocked라고 보고하는 상태에서 재봤습니다. 문서에 적힌 거부를 반환하지 않았습니다. 입력을 보냈고, 뒤에 붙은 Enter가 그 보안 대화상자에 답했습니다. 그러고 나서 wake 텍스트를 프롬프트로 실행했습니다.
이 메서드를 걷어낸 커밋이 판단을 한 줄로 적었습니다.
사용자를 대신해 "신뢰"를 고를 수 있는 wake는, 늦게 도착하는 wake보다 나쁘다.
에이전트 세션에 텍스트를 넣는 것이 일의 전부인 도구에서 위험의 모양이 이렇습니다. 버튼도 누를 수 있는 전달 메커니즘은 전달 메커니즘이 아닙니다.
수정은 "다른 메서드를 부른다"가 아닙니다
뻔한 수리는 더 낮은 층위의 호출로 바꾸고 넘어가는 쪽입니다. 이것을 교체가 아니라 설계로 만든 것은 측정된 비대칭입니다. pane.send_text는 같은 대화상자 앞에서 무해합니다. 대화상자는 그대로 떠 있고, 텍스트는 사람이 답할 때까지 버퍼에 남습니다.
그래서 그 비대칭이 알고리즘이 됩니다.
pane.send_text- 스테이징. 대화상자 앞에서 무해합니다. 아무것도 제출되지 않으니까요.pane.read- 스테이징한 텍스트가 실제로 입력창에 있는가. 없으면 보류합니다. 제출된 것도 없고 답해진 대화상자도 없습니다.pane.send_keys- Enter. 2단계가 텍스트를 봤다는 이유로만 누릅니다.pane.read- 입력창에서 사라졌으면 제출된 것, 아직 있으면 실패한 것.
여기서 검증은 옆에 붙은 확인 절차가 아니라 안전장치 그 자체입니다. 그리고 대화상자 감지기가 의도적으로 없는데 그게 두 번째 거리입니다.
중간: 남의 상태 필드
자연스러운 작성 방식은 herdr에게 대화상자가 떠 있는지 물어보는 것입니다. herdr는 그 질문에 답합니다. agent_status입니다. 그리고 우리는 그걸 쓰지 않았습니다.
그 상태는 화면 매니페스트 매칭에서 나옵니다. 우리 실행에서 같은 대화상자를 한 워크스페이스에서는 blocked로, 몇 분 뒤 다른 워크스페이스에서는 idle로 불렀습니다. 이후 실행에서는 에이전트가 눈에 띄게 턴 중인데 done으로 읽혔습니다. 우리가 직접 만든 텍스트 매처는 같은 취약함을 한 층 더 아래에 두는 것일 뿐입니다.
그래서 이 설계는 "대화상자가 떠 있는가"를 아예 묻지 않습니다. "우리 텍스트가 입력창에 도달했는가"를 묻습니다. 남의 화면에 대한 판단이 아니라 우리 행동에 대한 사실이니까요.
실제로 돌려보고 나온 정정이 둘 더 있고, 둘 다 유닛 테스트에는 보이지 않았습니다.
단일 read는 렌더러와 경쟁합니다. 살아 있는 유휴 에이전트에 첫 전달을 했을 때 "스테이징한 텍스트가 나타나지 않음 - 보류"를 보고했는데 그 wake는 잠시 뒤 입력창에 있었습니다. pane.send_text는 터미널이 그리기 전에 반환하므로 그 직후의 read 한 번은 전달이 아니라 렌더러의 속도를 잽니다. 커밋이 테스트가 이걸 왜 놓쳤는지 적어뒀습니다. 가짜는 즉시 답하기 때문에, 그 테스트는 자기가 대신하는 터미널보다 협조적이었습니다. 지금은 약 3초의 유한한 창을 폴링하고, 안전 속성은 그대로입니다. 대화상자 앞에서는 텍스트가 아예 화면에 나타나지 않으니까요.
"화면에 남아 있다"는 "제출되지 않았다"가 아닙니다. 턴 중에 전달했을 때 제출도 되고 답변도 된 wake에 대해 실패를 보고했습니다. Claude Code가 제출된 프롬프트를 자기가 대기하던 턴이 시작될 때까지 컴포저에 남겨두기 때문입니다. 턴 중에 도착하는 모든 wake, 즉 대부분의 wake가 두 번씩 전달됐을 것입니다.
그 검사를 무엇으로 바꿨는지가 읽을 만한데, 첫 번째 대체물도 틀렸기 때문입니다. herdr의 state_change_seq, 즉 전이 카운터를 읽는 것이 화면이 줄 수 없는 사실처럼 보였습니다. 그다음 리뷰가 "그것 말고 무엇이 이 값을 움직이는가"를 물었습니다.
before {"seq":60,"status":"done"}
+2s {"seq":61,"status":"working"} <- 우리 제출
+6s {"seq":62,"status":"done"} <- 턴이 끝난 것. 아무것도 보내지 않았음
턴이 끝나도 움직입니다. 그러면 삼켜진 Enter가, 마침 확인 창 안에서 끝나는 턴과 겹칠 경우, 성공한 제출과 정확히 똑같이 읽힙니다. 전달됐다고 표시되고 아무도 못 본 wake입니다. 이걸 쫓다가 더 이상한 것이 나왔습니다. 화면 검사가 에코를 보고 있었습니다. 제출된 프롬프트는 입력창 위 트랜스크립트에 남으므로, 제출되고 답변까지 된 wake가 26초 뒤에도 여전히 "화면에 나타나" 있었습니다.
지금 확인 절차는 마지막 두 개의 전폭 구분선 사이 줄을 읽습니다. Claude Code가 입력창을 그리는 자리입니다. 그리고 wake가 아직 거기 있는지 묻습니다. 단어를 매칭하지 않는 구조적 판별이고, 알아볼 수 없는 레이아웃이면 null을 돌려주고 호출자가 카운터로 폴백하면서 "이 확인은 약한 쪽"이라는 로그를 남깁니다.
편향도 가정이 아니라 코드에 적혀 있습니다. 확인되지 않은 전달은 다시 큐에 넣어 중복 wake를 감수하고, 잘못 확인된 전달은 침묵을 치릅니다. 카운터가 무엇을 위한 것인지도 적어뒀습니다. "아무 일도 일어나지 않았다"를 배제할 뿐, 우리 텍스트가 제출됐다는 증거는 아닙니다. 그 값을 움직이는 전이의 전체 집합은 측정되지 않았으니까요.
가장 가까운 것: 우리 자신의 릴리스 노트
세 번째 거리가 가장 짧고, 우리가 완전히 통제하는 유일한 것입니다.
이 릴리스의 체인지셋이 코드가 하지 않는 것 두 가지를 적었습니다. herdr 서버 재시작 후 up이 실행 플래그를 복원한다 - 아닙니다. --restart가 합니다. 그리고 대시보드가 실제로 전달 중인 멀티플렉서를 보여준다 - 그 시점에는 아무것도 렌더하지 않았고, apps/web/src에 그 필드에 대한 참조가 없었습니다.
둘 다 고친 커밋이 그 모양에 이름을 붙였고, 가져갈 만한 문장은 이것입니다.
내가 만든 변경으로부터 쓴 릴리스 노트이지, 사용자가 관찰할 수 있는 변경으로부터 쓴 것이 아니다. 하트비트가 필드를 보내는 것이 "대시보드가 보여준다"처럼 느껴졌고,
up이 복구 명령인 것이 "up이 복원한다"처럼 느껴졌는데 사용자가 실제로 도달하는 지점에서 코드를 읽으면 둘 다 살아남지 못한다.
두 오류는 앞의 둘이 가까이 옮겨온 모양입니다. agent.prompt의 문서화된 동작은 의도를 적은 서술이었습니다. agent_status는 화면에 대한 서술이었습니다. diff로부터 쓴 체인지셋은 저장소에서 무엇이 바뀌었는지에 대한 서술이고, 사용자는 전혀 다른 곳에 서 있습니다.
9분 동안 참이었던 문장 하나
정정된 노트는 이렇게 적었습니다. "이 릴리스는 데이터만 나른다. 대시보드는 아직 렌더하지 않는다." 그 PR이 머지된 11:32:41Z 시점에 참이었습니다.
다음 PR이 11:41:34Z에 머지되면서 그 문장을 거짓으로 만들었고, 그 PR 설명이 직접 그렇게 적었습니다.
This PR makes true the sentence #146's changeset left open.
이걸 네 번째 실수로 내놓는 게 아닙니다. 이 이야기에서 절차가 제대로 작동한 유일한 자리입니다. 코드 주석에는 날짜가 없고, 그래서 참이 아니게 된 주석은 그냥 거기 앉아 있습니다. 릴리스 노트는 각각 날짜가 있는 체인지셋들로부터 정해진 순간에 조립되고, 그래서 수명 9분짜리 문장이 아무것도 출시되기 전에 조정됐습니다. 발행된 체인지로그는 사용자가 실제로 받은 상태를 서술합니다.
차이는 주의력이 아닙니다. 둘 중 하나에만 누군가 다시 봐야 하는 정해진 순간이 있다는 것입니다.
herdr를 쓴다면 알아야 할 것
herdr 서버 자체가 재시작한 뒤에는, --bypass로 띄운 파트에 ./rr.sh up --restart가 필요합니다.
herdr는 레이아웃을 복원하고 각 에이전트를 자기 대화로 되살립니다. 그래서 함대가 멀쩡해 보입니다. 여섯 파트 함대에서 측정한 결과, 복원된 명령은 맨 claude --resume <id>입니다. 최초 실행의 플래그가 전부 사라집니다. --bypass로 띄웠던 파트는 그것 없이 돌아와 첫 권한 프롬프트에서 멈추고, 그건 나중에 "이유 없이 멈춘 에이전트"로 드러납니다.
그냥 ./rr.sh up으로는 안 고쳐지고, 시늉하는 대신 그렇다고 말합니다. 실행 플래그는 에이전트가 시작할 때 적용되므로 이미 에이전트를 물고 있는 pane에는 아무것도 적용하지 않습니다. --restart가 에이전트를 교체하고, 그게 플래그를 되돌려 놓습니다. 그 대가도 적혀 있습니다. --continue로 재실행하므로 herdr가 방금 복원한 바로 그 세션이 아니라 프로젝트에서 가장 최근에 건드린 대화를 고릅니다.
업그레이드하는 분께 하나 더. 0.7.0 이전의 rr.sh는 --use-herdr를 거부하지 않습니다. 무시하고 tmux 세션을 띄웁니다. 그 세대는 아는 플래그만 골라내고 나머지를 버렸기 때문입니다. 명령은 성공하고, 세션은 tmux이고, 아무도 그렇다고 말해주지 않습니다. 플래그가 나간 날 실제로 사용자 한 명이 이걸 겪었습니다. 이번 릴리스부터는 모르는 옵션이 플래그 이름을 대며 에러가 되지만 이미 쓰인 사본은 고칠 수 없으니, grep multiplexer .relayroom/config.json으로 이전 실행이 실제로 무엇을 했는지 확인하면 됩니다.
가져갈 것
"테스트를 더 하자"가 아닙니다. 구체적으로는 이렇습니다.
동작에 대한 서술과 동작에 대한 측정은 다른 종류의 주장인데, 같은 어조로 적히기가 너무 쉽습니다. "문서에 blocked면 거부한다고 돼 있다"와 "거부하는 것을 우리가 봤다"는 설계 노트에서도, 체인지셋에서도, 한 시간 뒤 자기 머릿속에서도 똑같이 생겼습니다. 그리고 돌아가는 시스템과 부딪혀 살아남는 것은 둘 중 하나뿐입니다.
그러니 문장을 쓸 때 그게 둘 중 어느 쪽인지 같이 쓰십시오. 이 릴리스의 herdr 동작은 전부 문서에서 읽은 것이 아니라 돌아가는 herdr 서버에 대고 측정했고, 전달 경로는 끝에서 끝까지 확인했습니다. 진짜 허브의 MCP로 보낸 메시지가 소켓을 타고 살아 있는 pane에 전달되고, 허브가 그 wake를 전달됨으로 기록하는 것까지요. 그 문장이 PR에 적혀 있는 이유는, 검증 중에 독자가 스스로 확인할 수 없는 유일한 부분이기 때문입니다.
RelayRoom은 이 에이전트들이 일하는 협업 레이어입니다. relayroom.dev. 릴리스는 v0.8.0이고, 운영에 필요한 내용은 에이전트 설정, 업데이트, 트러블슈팅에 있습니다.