본문 바로가기
C.W.K.
Stream
Lesson 05 of 07 · published

사람을 기다리는 채팅

~16 min · human-in-the-loop, observability, permissions, coordination

Level 0메모 쪽지
0 XP0/41 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

아무도 못 본 프롬프트에서 아홉 시간 반

2026-10-15, Coop이 todo #460을 맡길 Claude Code 워커를 백그라운드로 띄웠어. 아빠의 브라우저 앱을 고치는 일이었어. 그 워커가 처음 Write를 하려는 순간, Claude Code 2.1.301이 막 들여온 백그라운드 세션용 가드에 걸렸어. 다른 세션들과 같이 쓰는 체크아웃이라면, 백그라운드 채팅은 첫 편집 전에 자기를 격리하라는 가드야. 워커는 가드가 시키는 대로 했어. 자기 job 폴더 밑에 git worktree를 하나 만들고, 거기로 EnterWorktree를 불렀지.

그 호출은 사람의 허락이 있어야 넘어가. 백그라운드 채팅이 허락을 구하는 방법은 하나뿐이야. 프롬프트 앞에 멈춰서 기다리는 거. 그런데 아빠가 폰으로 이런 채팅에 들어가는 문인 Remote Control은 그 프롬프트를 끝내 안 보여줬어. 그래서 채팅은 아홉 시간 반 동안 거기 서 있었어. Coop도 뭔가 잘못됐다는 건 처음부터 알았어. activity 판독이 needs_input이라고 했거든. 근데 아는 게 딱 거기까지였어. chief가 올린 에스컬레이션은 아빠한테 "가서 봐"라고밖에 말을 못 했고, 다른 나라에서 폰을 든 사람한테 "가서 봐"는 따를 수 있는 지시가 아니야.

이 사고 하나에 실패가 둘 숨어 있었어. 하나, 그 질문의 답을 Coop은 미리 알고 있었는데 주지 않았어. 둘, 채팅이 정말로 사람을 기다릴 때, Coop은 기다린다는 사실은 봤지만 무엇을 기다리는지는 못 봤어. 이 레슨은 그 둘을 고친 이야기고, 덤으로 그 수리가 다음 날 직접 만들어낸 세 번째 실패 이야기야.

이미 아는 답은 미리 줘

Coop 워커가 별도 worktree에서 일해야 할까? 이 질문엔 가드가 생기기 훨씬 전에 Coop이 답을 내놨어. 워커는 받은 체크아웃에서 일해. 배정이 가리키는 바로 그 체크아웃이야. 같은 체크아웃에 다른 세션이 있으면 작업 봉투가 그걸 알려주고(트랙 3의 shared_checkout 공개), 자기 파일만 스테이징하라고 일러. job 폴더 밑 worktree에서 한 일은 다른 세션도, office의 pull도, 리뷰도 찾아볼 생각을 안 하는 곳에 놓이게 돼.

그래서 2026-10-16(todo #463)부터 Coop이 띄우는 Claude 워커는 플래그를 두 개 더 달고 나가. 하나는 --settings '{"worktree":{"bgIsolation":"none"}}'로, 그 세션에서 백그라운드 격리 가드를 꺼. 다른 하나는 --disallowedTools EnterWorktree로, 그 도구를 아예 치워. 눈여겨볼 디테일이 두 개 있어.

  • 순서가 중요해. --disallowedTools는 다음 옵션이 나올 때까지 오는 단어를 전부 도구 이름으로 먹어. 그런데 명령줄 맨 끝 단어는 셋업 프롬프트야. 그래서 이 플래그들은 --effort 앞에 와. 그 옵션이 목록을 끊어 주거든. 맨 뒤, 프롬프트 바로 앞에 두면 셋업 프롬프트 전체가 금지할 도구 하나로 읽혀.
  • 답은 재시작에서도 살아남아야 해. Claude의 백그라운드 데몬은 세션의 실행 옵션을 job 기록(respawnFlags)에 저장해. 그래서 나중에 깨운 세션(다음 레슨)도 두 플래그를 그대로 지녀. 실행할 때 한 번 준 답이 첫 깨움에서 사라지면, 같은 프롬프트가 고스란히 돌아와.

Coop이 뭘 안 했는지도 봐. 권한 프롬프트에 대답하지 않았어. 질문이 나오기 전에 질문 자체를 없앤 거야. Coop은 네이티브 권한 프롬프트에 절대 대답하지 않고, chief도 못 해. 동료는 아빠의 권한을 대신 내줄 수 없어. 이 플래그들은 아빠가 내릴 결정을 건너뛰는 우회로가 아니야. Coop 설계가 이미 내린 결정을, CLI가 읽을 수 있는 자리에 적어둔 거야.

뭘 기다리는 건데?

미리 답할 수 없는 질문도 있어. 워커가 아빠한테 두 레이아웃 중 뭘 원하냐고 묻거나, 아무도 예상 못 한 명령에 허락이 필요할 때. 이럴 때 쓸모 있는 건 needs_input이라는 사실이 아니라 질문 그 자체야. Claude의 세션 레지스트리는 채팅이 뭘 기다리는지 절대 말 안 해. transcript는 말해.

#463 이후로 needs_input 판독엔 waiting_on 한 줄이 따라와. 채팅 transcript의 마지막 256 KB에서 읽어.

  • 결과를 못 받은 도구 호출이 곧 질문이야. 그 호출에 대한 권한 프롬프트로, 입력값까지 같이 인용해.
  • AskUserQuestion 호출은 질문과 선택지로 인용해.
  • ExitPlanMode 호출은 계획 승인 요청이야.
  • 걸려 있는 호출이 없으면, 채팅의 마지막 말이 곧 묻는 내용이야.

엔진은 이걸 세션의 activity_waiting에 저장하고, 판독이 가는 곳마다 runtime.waiting_on으로 보여줘. 이제 에스컬레이션은 이 채팅이 바로 이 명령을 돌려도 되냐고 묻고 있어라고 말할 수 있어. 아빠가 어디서 답하면 되는지도 같이.

원인보다 오래 남은 상태

수리는 아침에 나갔어. 저녁이 되자 그 수리가 거짓 경보를 울렸어. 2026-10-16, todo #467 워커가 09:25:16에 MCP 도구 호출 하나에서 Claude 레지스트리상 waiting이 됐어. 그러고도 계속 일했어. 21분을 더, 커밋 하나와 긴 git push까지 하고 턴을 정상적으로 끝냈지. 레지스트리 항목은 그 뒤로 한 번도 안 바뀌었어. 새 감지기는 그걸 믿고 아빠한테 채팅이 아빠를 기다린다고 알렸어. Remote Control에선 멀쩡히 돌고 있었는데.

트랙 4에서 제일 오래된 교훈이 옷만 갈아입고 온 거야. Codex 관찰자는 도는 턴을 interrupted라고 불렀고, Claude 레지스트리는 일하는 채팅을 waiting이라고 불렀어. 둘 다 다른 프로세스가 이미 지나간 순간을 정직하게 전한 거였어. 그래서 todo #468부터 레지스트리의 waiting은 가설이고, 판정은 transcript가 해.

  1. 기다림이 진짜인 경우. 상태가 적힌 시각에서 10초 안쪽(WAIT_SLACK. 한 답변의 호출들은 몇 초 간격으로 찍혀)에 나간 호출이 아직 결과를 못 받았으면 진짜야. 그 호출을 waiting_on으로 인용해.
  2. 돌고 있는 명령은 질문이 아니야. Claude는 명령을 돌리기 전에 물어봐. 그러니까 명령이 지금 채팅 프로세스의 자식 셸로 돌고 있는 Bash 호출은 기다리는 게 아니라 일하는 중이야. 이걸 어느 맥에서든 똑같이 읽으려고 호스트 헬퍼에 children 프리미티브가 생겼어.
  3. 그런 호출은 없는데 transcript가 그 뒤로 진도를 나갔으면, 상태가 낡은 거야. 최신 이벤트가 턴을 끝냈으면 idle, 아니면 working이야.
  4. 둘 다 아니면, 예전처럼 채팅은 마지막 말을 기다리는 거야.

chief를 깨우는 유일한 판독

트랙 6의 깨움 레슨은 activity 판독이 기록만 하고 chief는 안 깨운다고 말해. 예외 하나만 빼고. #463 전엔 그 예외가 없었어. 바로 이게 그 예외야. 일을 쥐고 있는 채팅이 15분 동안 needs_input으로 읽히면(NEEDS_INPUT_SECONDS. 판독은 heartbeat마다 반복되니까, 깨움이 늦어 봐야 heartbeat 한 번이야), 그 채팅 바인딩에 걸린 대기 중 의무가 전부 그 자리에서 기한이 돼. 파킹된 것도 풀려. chief는 그때 알던 걸로 파킹한 거고, 사람 앞에서 멈춘 대기는 새 소식이니까. 이건 대기 한 번에 딱 한 번 일어나. 세션이 어느 대기를 이미 에스컬레이션했는지 기억하거든(activity_escalated에 그 대기가 시작된 시각이 남아). 그래서 한 시간 기다리는 채팅은 chief를 네 번이 아니라 한 번 깨워.

chief 계약은 그 깨움으로 뭘 할지도 정해 놨어. 아빠한테 딱 한 번 에스컬레이션하고, waiting_on을 인용하고, 아빠가 어디서 답하면 되는지 말해. 배정의 Remote Control 링크든, 채팅이 도는 맥에서의 claude attach든. 앞서 에스컬레이션한 뒤에 새 질문이 나오면 그건 새 증거라서, chief의 중복 방지 장치도 통과시켜. 사람한테 걸려 멈춘 채팅은 조용히 기다리는 게 틀린 기본값인 유일한 경우야. 그걸 풀 수 있는 유일한 사람이 정작 그 사실을 모르니까.

설계가 이미 정한 질문은 미리 답해 두고, 나머지는 한 글자도 바꾸지 말고 올려. 시스템이 답할 수 있었던 프롬프트에 걸린 워커는 설정 버그야. 진짜 질문에 걸린 워커는 소식이고, 그 소식의 본체는 기다린다는 사실이 아니라 질문 자체야.

Code

Coop의 activity.py: 'waiting' 상태를 transcript로 확인하기 (축약한 발췌)·python
WAIT_SLACK = 10   # one reply's calls land seconds apart

def waiting_status(host, data, pid, since):
    """(activity, source, waiting) for a chat the registry calls 'waiting', checked against its transcript."""
    read = transcript_calls(data)
    cutoff = since + WAIT_SLACK if since is not None else None
    held = [call for call, when in read['pending'] if cutoff is None or when is None or when <= cutoff]
    if held:
        for call in reversed(held):
            if not bash_running(host, pid, call):      # Claude asks before it runs a command
                return 'needs_input', 'claude: registry status waiting', describe_call(call)
        return 'working', 'claude: its Bash call is running (registry status waiting)', None
    if cutoff is not None and read['newest'] is not None and read['newest'] > cutoff:
        if read['ended']:
            return 'idle', 'claude: transcript turn ended after registry status waiting', None
        return 'working', 'claude: transcript moved on after registry status waiting', None
    waiting = _compact('its last message: ' + read['last_text'][-500:], 600) if read['last_text'] else None
    return 'needs_input', 'claude: registry status waiting', waiting
직접 돌려보는 최소 스케치: 'waiting'인 채팅이 정말 사람을 기다리는 걸까?·python
"""A minimal sketch (not Coop's code): is a chat the registry calls 'waiting'
really waiting on a person? Decide from the transcript, not the status."""
WAIT_SLACK = 10  # calls from one reply land seconds apart


def reading(events, status_at, running=()):
    """events: (time, kind, id, detail) with kind 'call', 'result', 'text' or 'end'."""
    pending, newest, ended, last_words = {}, None, False, None
    for when, kind, ident, detail in events:
        newest = when if newest is None else max(newest, when)
        ended = kind == 'end'
        if kind == 'call':
            pending[ident] = (when, detail)
        elif kind == 'result':
            pending.pop(ident, None)
        elif kind == 'text':
            last_words = detail
    held = [(i, d) for i, (w, d) in pending.items() if w <= status_at + WAIT_SLACK]
    for ident, detail in reversed(held):
        if ident not in running:            # a running command asked nobody
            return 'needs_input', detail
    if held:
        return 'working', None
    if newest is not None and newest > status_at + WAIT_SLACK:
        return ('idle' if ended else 'working'), None
    return 'needs_input', last_words


cases = {
    'prompt nobody saw': ([(0, 'call', 'c1', 'permission for EnterWorktree')], 1, ()),
    'status outlived it': ([(0, 'call', 'c1', 'an MCP call'), (3, 'result', 'c1', ''),
                            (900, 'call', 'c2', 'git push'), (1260, 'result', 'c2', ''),
                            (1300, 'end', None, '')], 1, ()),
    'long command': ([(0, 'call', 'c1', 'permission for Bash: pytest')], 1, ('c1',)),
    'asked in words': ([(0, 'text', None, 'Which layout do you want?')], 1, ()),
}
for name, (events, status_at, running) in cases.items():
    print(f'{name:20}', reading(events, status_at, running))

# Expected output:
# prompt nobody saw    ('needs_input', 'permission for EnterWorktree')
# status outlived it   ('idle', None)
# long command         ('working', None)
# asked in words       ('needs_input', 'Which layout do you want?')
Coop이 띄우는 모든 Claude 워커에 붙는 두 플래그, --effort 앞에 (예시. 제목과 프롬프트는 생략)·bash
claude --bg --remote-control "<title>" --name "<title>" \
  --settings '{"worktree":{"bgIsolation":"none"}}' \
  --disallowedTools EnterWorktree \
  --effort high "<setup prompt>"
# --disallowedTools takes every word up to the next option, so it never sits right before the prompt.

External links

Exercise

네가 돌리는 자동화 에이전트나 작업 중에 멈춰서 사람을 기다릴 수 있는 걸 하나 골라. 수동 승인이 있는 CI 작업, 명령을 돌리기 전에 묻는 에이전트, 중간에 묻는 설치 프로그램 같은 거. 그게 멈춰 설 수 있는 질문을 전부 적어. 그다음 질문마다 갈라. 네 설계가 이미 답을 아는 질문이면, 실행할 때 그 답을 주는 플래그나 설정, 정책을 적어. 사람만 답할 수 있는 질문이면, 경보에 실어야 할 한 줄을 적어. 정확한 질문과, 답할 자리. 마지막으로 위 스케치를 돌리고, 레지스트리는 waiting이라는데 transcript엔 그 뒤에 커밋이 찍힌 경우를 하나 넣어봐. waiting이 아니라 working으로 읽혀야 해.
Hint
멈춘 에이전트 대부분은 누군가 이미 한 번 정한 질문에 걸려 있어. 문서나 습관 속에만 있고 도구가 읽는 자리엔 안 적힌 답이지. 답이 산문에만 있으면 에이전트는 계속 물어. 스케치에선, 상태 시각에 여유 시간을 더한 것보다 늦게 나간 호출은 채팅이 멈춰 선 이유일 수가 없어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파— warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.