Harness Internals — Execution Modes

auto-issue 워커와 대화형 Claude Code 세션은
세 곳에서만 갈린다

문제는 이렇다. 내가 대화하며 쓰는 Claude Code 세션은 이틀 사이 사고를 세 번 냈다. 그런데 같은 설정으로 GitHub 이슈를 혼자 처리하는 auto-issue 헤드리스 워커(headless worker — 화면도 사람도 없이 명령 한 줄로 켜져서 이슈 하나를 끝내면 꺼지는 자동 작업자)는 같은 사고를 한 번도 내지 않았다. 둘은 동일한 user scope 기계(사용자 계정 전체에 적용되는 훅·규칙·스킬 설정 묶음) 위에서 돈다. 그래서 어디서 갈리는지 보려고 hook pipeline(훅 파이프라인 — 세션의 정해진 순간마다 자동으로 끼어드는 검사 스크립트의 줄) · spawn(스폰 — 자식 작업을 새로 띄우는 일) · teammate(팀메이트 — 메시지를 주고받는 동료 에이전트) · subagent(하위 에이전트 — 일을 맡기고 결과만 받는 자식 에이전트) · skill(스킬 — 정해진 절차를 담아 둔 명령 묶음) 다섯 축을 user/project scope 레벨에서 실측 비교했다. 분기는 정확히 세 곳에서 난다.

전제 — 둘 다 같은 user scope 기계 위에서 돈다

결론부터 말하면 출발점은 완전히 같다. 사용자 홈 폴더의 ~/.claude 디렉토리에 든 훅·규칙·스킬·플러그인은 프로세스 종류를 가리지 않는다. 대화형 세션도 헤드리스 워커도 같은 기반을 로드한다. 차이는 전부 그 위층에서 시작된다. 바로 project scope(프로젝트 폴더 안의 .claude/ 설정으로, 그 프로젝트에서만 적용되는 층)를 어떤 경로로 손에 넣느냐다.

54user scope hooks
16hook events
~125skills 목록 주입
3분기 지점
실행 기계 스택 — user scope는 공유, project scope에서 분기 대화형 세션 project scope = cwd의 .claude/ (있으면) 실측 hugh-soft: settings.json 없음 → hook 0개 auto-issue 워커 project scope = 워크트리 .claude/ (설치됨) 실측 워크트리: hooks + settings.json 풀셋 동일 로드 동일 로드 USER SCOPE — ~/.claude settings.json hooks 54개 · 16 events · CLAUDE.md + rules 주입 · skills ~125 · plugins/MCP 모든 Claude Code 프로세스에 동일 로드 — 여기에는 차이가 없다 실측 2026-07-31~08-01 · ~/.claude/settings.json · bs-hanyang 워크트리 group-310-311-312
동일한 user scope 기반 위에서 project scope 획득 경로만 갈린다. 대화형은 cwd(현재 작업 디렉토리)에 설정이 있으면 그대로 읽는다. 워커는 부트스트랩(워커를 띄우기 전에 작업 환경을 준비하는 단계)이 워크트리(같은 저장소를 별도 폴더로 한 번 더 받아 둔 작업 사본)에 설치한다.
같은 훅, 다른 발화 프로파일

훅이 걸리는 16개 이벤트(세션 시작·프롬프트 제출·도구 실행 전처럼 훅이 끼어드는 순간의 종류)는 양쪽 모두 존재한다. 훅은 대개 게이트(gate — 조건을 못 맞추면 다음 단계로 못 넘어가게 막는 검문소)로 동작한다. 다른 것은 발화 빈도(훅이 실제로 실행되는 횟수)와 실패 완화 장치의 위치다. 대화형은 사용자가 말을 걸 때마다, 즉 턴마다 주입(훅이 모델의 컨텍스트, 곧 한 번에 읽는 작업 메모리에 밀어 넣는 규칙·기억·안내문)이 반복된다. 워커는 단 한 번의 프롬프트에 모든 주입이 실린다.

이벤트 대화형 세션 auto-issue 워커
SessionStart ×4 세션 시작 시 1회 디스패치마다 1회(dispatcher, 곧 보드에서 이슈를 꺼내 워커를 띄우는 조정 프로그램이 이슈를 배정할 때마다). 매번 fresh 컨텍스트(아무것도 남지 않은 새 작업 메모리)로 시작한다
UserPromptSubmit ×9 매 사용자 턴 재발화. pending 신호(아직 처리되지 않은 대기 알림)와 memory 주입(과거 대화에서 뽑아 둔 기억)이 턴마다 반복된다 정확히 1회. claude -p(프롬프트 한 줄을 넘기고 결과만 받는 비대화 실행 명령) 단발 프롬프트에 전부 실린다
Stop ×10 차단이 사용자에게 표면화되고, 대화로 수습한다 런(워커 실행 1회) 내부 루프 → attempts cap(재시도 횟수 상한) → dispatcher 산출물 게이트(디스패처가 워커의 결과물을 검사해 통과 여부를 정하는 마지막 검문소)가 최종 방어
Pre/PostCompact 오래 산 세션에서 실제로 발화한다. 압축(compact — 작업 메모리가 꽉 차면 요약해 줄이는 동작)만 반복하고 일은 못 하는 스래싱 실측 사실상 미발화. 한도를 넘기면 BIGCTX 모델 스왑 재기동(더 큰 컨텍스트 창을 가진 모델로 바꿔 워커를 처음부터 다시 띄우는 방식)으로 대응한다. 완화 장치가 프로세스 외부에 있다
PreToolUse ×13 exit-2 게이트(훅이 종료 코드 2를 돌려주면 그 도구 실행을 막는 차단 장치) + 권한 프롬프트 이중(사용자에게 허용 여부를 묻는 창까지 한 겹 더) exit-2 게이트만 남는다. --dangerously-skip-permissions(권한 확인 창을 전부 건너뛰는 실행 옵션)로 프롬프트 계층을 제거한다
Project Scope의 반전 — 실측

통념과 반대다. 흔히 사람이 붙어 있는 대화형 세션이 더 많은 검사를 받을 것이라 생각한다. 실측은 거꾸로다. 지금 이 대화형 세션(hugh-soft, 이 글을 쓰고 있는 프로젝트)은 project hook 0개로 돌고, bs 워커(bs-hanyang 프로젝트의 이슈를 맡은 auto-issue 워커)의 워크트리에는 hooks와 settings.json(훅 등록과 권한 설정을 담는 Claude Code 설정 파일) 풀셋이 실재한다. 본 repo(저장소)에서 이 파일들은 git 미추적(git이 관리하지 않는 파일)이라 워크트리로 저절로 전파될 수 없다. settings.local.json(개인 로컬 전용 설정 파일로, 커밋되지 않는다)의 존재가 증거다. 부트스트랩이 디스패치 시점에 설치한 것이다. 워커가 project scope 게이트를 더 완전하게 받는다.

in-process 상속 vs OS-process 격리

핵심은 자식이 무엇을 물려받느냐다. 대화형의 spawn은 같은 하네스(harness — 모델을 감싸고 도구 실행·훅·검증을 맡는 실행 틀로, 여기서는 Claude Code 프로세스 자체를 가리킨다) 프로세스 안에서 일어난다. 그래서 자식은 부모의 컨텍스트와 대형 주입을 그대로 물려받는다. 이것이 in-process(같은 프로세스 안) 상속이다. 워커의 spawn은 별도 OS 프로세스라 아무것도 물려받지 않는다. 이것이 OS-process(운영체제 프로세스 단위) 격리다. 대신 환경도, 제어 수단도 완전히 다르다.

SPAWN 계층 — 무엇을 물려받고, 무엇으로 제어되는가 대화형 세션 — IN-PROCESS Claude Code 세션 프로세스 사용자 launch shell env 상속 Agent tool spawn subagent · teammate 세션 컨텍스트·대형 주입 상속 SubagentStart/Stop hook 발화 실측 07-31: Prompt is too long 대형 주입 상속 → 스폰 실패 → autocompact 스래싱 → 수동 종료 제어: TaskStop 통지: mailbox idle_notification auto-issue 워커 — OS-PROCESS auto_issue_loop.sh 디스패처 launchd/cron 최소 env — PATH 핀 필요 nohup · tmux OS spawn claude -p 헤드리스 프로세스 워크트리 cwd · 전용 워커 모델 --dangerously-skip-permissions 컨텍스트 초과 → BIGCTX 모델 스왑 완화 장치가 프로세스 외부(루프)에 있다 압축 대신 재기동 제어: run-locks · 2h timeout kill · 워치독 재디스패치 쿨다운 30분 왼쪽: 자식이 부모의 컨텍스트를 물려받는다 · 오른쪽: 자식은 아무것도 물려받지 않는다
spawn 실패의 형태도 다르다. 대화형은 상속된 컨텍스트가 한도를 넘겨 죽는다. 워커는 시간 제한·잠금(lock — 같은 이슈를 두 워커가 동시에 잡지 못하게 하는 표시)·쿨다운(실패 뒤 재시도까지 두는 대기 시간)으로 외부에서 관리된다.
통신 채널과 상태 신원

세 번째 분기가 사고를 갈랐다. 대화형의 팀메이트는 주소 지정 가능한 mailbox 실체(이름을 지정해 메시지를 보낼 수 있는 우편함을 가진 에이전트)고, 워커는 팀메이트가 아니다. 그리고 상태 신원(세션 ID·ledger·memory scope, 곧 이 프로세스가 "나"라고 알고 있는 세션 번호·작업 기록·기억의 범위)의 격리 수준이 실제 사고 3건의 발생 여부를 정확히 갈랐다. 여기서 ledger는 원장(ledger — 작업 완료를 주장할 때마다 근거를 줄줄이 적어 두는 기록 파일)이고, attestation(검사를 통과했다는 도장으로, 코드 상태가 바뀌면 효력을 잃는다)은 검증 서명이다.

interactive — shared state

mailbox + 공유 디렉토리

"말은 통하지만, 상태가 섞인다"

  • SendMessage(다른 에이전트에게 메시지를 보내는 도구)로 말을 걸고, idle_notification(상대 에이전트가 일을 마치고 쉬는 상태가 되면 오는 알림)을 받는다 (실측: 리뷰어 통지 2회 배달)
  • work-recheck(작업 완료 주장을 다시 뜯어보는 재검증 게이트) ledger를 동시에 열린 다른 세션들과 같은 디렉토리에서 공유한다
  • 실측: 내 ledger에 다른 프로젝트의 파일 경로가 2,719건 혼입됐다(섞여 들어왔다)
  • memory-bank(과거 대화에서 뽑은 사실을 세션마다 주입하는 기억 플러그인) 주입 = 이 프로젝트에서 축적한 facts(사실 기록) + global(전역 사실)
worker — isolated state

board + lock + 코멘트

"말은 안 통하지만, 상태가 깨끗하다"

  • SendMessage 불가. GitHub 코멘트·보드 상태·산출물 파일·lock이 소통 수단의 전부다
  • 보드(GitHub 프로젝트 보드 — 이슈의 진행 상태를 칸으로 관리하는 판) 상태 변경은 dispatcher 단일 writer(쓰는 주체가 디스패처 하나뿐)
  • 세션 ID·transcript(대화 전문 기록)·ledger를 워크트리 경로 기준으로 새로 발급한다
  • memory-bank 주입 = global facts만 (워크트리 slug, 곧 경로에서 만든 짧은 식별 이름 기준)
사고 ① 07-31

스폰 실패 · 스래싱

백그라운드 subagent가 세션의 대형 주입을 상속했다. 그 결과 Prompt is too long(프롬프트가 모델 한도보다 길다는 오류)으로 죽었다. 이어서 autocompact(작업 메모리가 차면 자동으로 요약 압축하는 기능) 스래싱으로 자원만 소모했다.

2회
실패 통지
0
워커 구조 발생
사고 ② 07-31

ledger 오염

공유 상태 디렉토리에 다른 세션·다른 프로젝트의 경로가 혼입됐다. 이 세션과 무관한 파일 변경이 검증 상태를 오염시켰다.

2,719
혼입 경로
0
워커 구조 발생
사고 ③ 08-01

attestation 반복 무효화

6시간마다 도는 스케줄러가 공유 경로의 파일 .seen.json(이미 본 항목을 적어 두는 목록)을 갱신했다. 그때마다 검증 서명이 stale(낡아서 효력 없음)이 됐다. 작업을 하지 않았는데도 게이트가 다시 막혔다.

3회
거짓 stale
0
워커 구조 발생
스킬은 같게 로드되고, 다르게 호출된다
load — 동일

로드 경로

"목록은 양쪽에 똑같이 주입된다"

  • user scope 스킬 ~125개 목록이 양쪽 SessionStart(세션 시작 이벤트)에 동일하게 주입된다
  • project 스킬은 cwd의 .claude/skills 폴더 기준이다
  • 샘플 워크트리: skills/ 폴더 없음 → 워커는 user scope만 사용한다
  • 헬퍼 스크립트 auto_issue.py(이슈 선택과 워커 프롬프트 조립을 맡는 보조 프로그램)에는 ~/.claude(사용자 홈의 Claude 설정 폴더)를 프로젝트 폴더로 잘못 읽지 않게 막는 가드가 있다 (auto_issue.py:276)
invoke — 상이

호출 주체

"재량이냐, 계약이냐"

  • 대화형: 사용자가 치는 /명령(슬래시 명령) + 모델의 자율 판단으로 호출한다
  • 워커: build_issue_prompt(이슈 내용으로 워커 프롬프트를 조립하는 함수)가 계약으로 호출을 지정한다
  • "/init-project(프로젝트 설정과 계획을 만드는 스킬) → /team(구현과 검증을 맡는 팀 오케스트레이터 스킬)" 순서가 프롬프트에 명문화돼 있다
  • 스킬이 파이프라인(정해진 순서로 이어지는 처리 단계의 줄) 단계로 소비된다. 재량의 여지가 없다
1
분기점 ① project scope 획득 경로
대화형 = cwd에 있으면 로드 · 워커 = 부트스트랩이 워크트리에 설치
2
분기점 ② spawn 계층
in-process 상속(컨텍스트·주입 물려받음) · OS-process 격리(lock·timeout으로 외부 제어)
3
분기점 ③ 상태 신원
공유 디렉토리(ledger·attestation 오염 가능) · 워크트리별 신규 발급(구조적 격리)
Verdict

user scope 기계는 완전히 동일하다. 갈리는 곳은 세 곳뿐이다. 이 세션이 이틀간 겪은 사고 3건(스폰 실패·ledger 오염·attestation 무효화)은 전부 대화형 쪽 구조의 약점이 발화한 것이다. 워커 구조에서는 셋 다 설계상 발생하지 않는다. 그렇다고 워커가 늘 답은 아니다. 대화를 주고받는 중에 비로소 과녁(무엇을 만들어야 하는지)이 정해지는 작업은, 동결된(처음 적힌 그대로 고정된) 이슈로는 표현할 수 없다. 스펙을 문장으로 적을 수 있으면 워커에게, 적을 수 없으면 대화형에.