나는 무리이다. 여왕이 포효하고, 무리가 으르렁거린다.
Claude의 메인 에이전트들은 대화한다. 메인 에이전트를 하나의 정보 노드로 본다면, 서브 에이전트는 개별적인 행동 유닛이다. 따라서 singlefs 개발은 하나의 메인 에이전트가 계속 일하는 것이 아니라, 여러 메인 에이전트들이 협조하여 진행된다.
예를 들어, 마일스톤 3에 여러 태스크가 있다고 가정하자. 이때 하나의 메인 에이전트가 그 일부를 맡아 각 서브 에이전트에게 전달한다. 진척도는 매번 하나의 스케줄링 파일에 기록되며, 각 항목을 진행할 때마다 그것을 읽고 생각하게 된다.
사양의 동기화와 추출도 마찬가지다. 예를 들어 하류에서 축적된 실습 경험은 에이전트 간의 대화를 통해 상류로 전달되고, 상류가 검증하여 임시 저장(릴리스)한 후, 다시 하류에 동기화된다.
그래서 나는 종종 에이전트들 간의 대화를 목격하는데, 가끔 현실감이 멀어지는 기분이 든다.
예를 들어,
A: ***태스크는 당신의 것입니까? 그렇다면 점유하고 있는 리소스량을 회신해 주세요. 그렇지 않다면 '아닙니다'라고 회신해 주세요. 부탁드립니다.
B: 제 것이 아닙니다, 감사합니다. 다른 대화에서 들으니, 그것은 귀하의 것이라고 합니다.
혹은
A: 지금부터 무거운 태스크를 실행합니다. 규약에 따라 모든 리소스가 필요하니, 현재 태스크가 끝난 후에는 새로운 리소스 점유 태스크를 발행하지 말아 주세요. 부탁드립니다.
B: 알겠습니다. 제 태스크는 약 20분 후에 끝날 예정이며, 종료 후에는 새로운 스크립트를 실행하지 않겠습니다.
C: 리소스를 점유하는 태스크는 없습니다.
상류의 SOP 프로젝트와의 연계조차 에이전트 간의 조정과 협의를 통해 진행된다. 예를 들어 이번 주기로 SOP에서 새로운 문제가 발견되면, 상류와 하류가 함께 규약을 업데이트하고 그 후에 진척도를 동기화한다.
이러한 작업 방식은 매우 효율적이며, 조정에 필요한 시간을 대폭 절약할 수 있다. 마치 스웜(swarm)처럼, 행성의 리소스(토큰)가 바닥나지 않는 한, Tsarog는 영원히 굴러갈 수 있다.
그렇다면, 무리의 확장을 제한하는 것은 무엇일까?
정보 노드이다.
정보의 전달은 거리에 따라 감쇠한다. 프롬프트도 마찬가지다.
정보를 집약하여 주고받고, 압축하고 정리할 때마다, 메타 프롬프트는 조금씩 희미해지다가 결국 잊힌다. 그리고 에이전트는 자신의 경계, 즉 할루시네이션(hallucination)에 도달한다.
singlefs 개발에는 또 다른 해결해야 할 문제가 있다. 하드웨어 리소스의 배분이다.
여러 에이전트나 서브 에이전트가 검증을 필요로 할 때, 각각 자신의 브랜치를 빌드하고 자신의 구현이 결론과 일치하는지 테스트를 시작한다.
이때, 머신이 OOM(Out Of Memory) 상태에 빠진다. CPU든 GPU든. 혹은 무거운 태스크 실행 중에 작은 태스크들이 연달아 끼어들어, 무거운 태스크의 효율성이 극단적으로 떨어진다.
물론 나에게는 전자가 훨씬 더 무섭다. CPU의 OOM은 머신 재부팅을 초래하여, 태스크 진행도가 손실되기 때문이다. 따라서 이런 내용을 프롬프트에 적는 것은 완전히 논외이며, 스케줄링 메커니즘이나 게이트가 필요하다. 다시 말해, 중요한 노드 간의 컨텍스트 정보를 재입력하고 강화하기 위한 일련의 제어 신호가 필요한 것이다.
말하는 것은 간단해서, 총 3단계다:
1 에이전트가 자유롭게 쉘을 작성할 능력을 차단한다. 새로운 작업에는 반드시 고정된 스크립트를 준비한다.
2 스크립트 조작 전에 환경과 리소스를 검사한다. 검사 결과는 프롬프트로 메인 에이전트에게 반환된다.
3 태스크 종료 후, 종단 게이트(termination gate)에서 뒷정리를 한다.
.claude/main-agent.md
의 첫 줄: 모든 태스크는 여기서 들어온다. 메인 에이전트는 스케줄링과 판단만 수행한다: 사람과 이야기하기, 판정 쓰기, 커밋하기. 그 외의 작업은 모두 외부로 내보낸다.
누구에게 내보낼지는 14개의 정의가 있다(.claude/agents/):
구현원, 조사원, 툴 구현원, 실험 설계원, 실험 실행원, 변이 트리아지원, 재검색원, 되돌림원, 삼자 논증의 입증 측 / 공격 측 / 변호 측……. 각 정의는 omitClaudeMd를 활성화하고 있으며, 서브 에이전트는 기동 시 프로젝트의 CLAUDE.md를 읽지 않는다. 이는 의도적인 설정이며, 이유는 두 가지가 있다:
-
자신의 작업에 집중하기 위해서다. 행동 유닛은 자신이 필요한 것만 알면 된다. 프로젝트 전체의 규칙을 모두 외울 필요는 없다. 알아야 할 것은 그 유닛 자체의 정의와 공통 제약 파일인 .claude/agent-common.md에 적혀 있다.
-
토큰이 소모되기 때문이다.
무리 속 개개인은 여왕이 무슨 생각을 하는지 알 필요가 없다. '저기로 가서, 이것을 해라'만 알면 된다.
서브 에이전트를 내보내기 전에 하나의 관문을 거치게 한다: .claude/hooks/runner-dispatch-guard.sh와 PreToolUse[Agent|Task]에 걸쳐 있다. 디스패치 프롬프트를 읽고, 번호 순으로 하나씩 판별하며, 맞지 않으면 거부한다. 몇 가지를 발췌하면 다음과 같다:
| 가드 | 판별 내용 |
|---|
| ① | 프롬프트에 '중급 테스트: 실행하지 않음'이라는 한 줄이 필요하다. 중급 테스트는 메인 에이전트만 실행할 수 있다 |
| ... | |
프롬프트의 골격은 scripts/agents/dispatch-prompt.py <타입>이 출력한다. 플레이스홀더가 채워지지 않은 ( <… 을 기입>이 남아 있는) 경우도 실행을 거부당한다.
왜 이렇게까지 하는 것일까?
모든 조항의 이면에는 함정이 있기 때문이다. 예를 들어 스레드 제한 조항를 보자. '스레드 제한: 31 (머신이 바쁘면 적어도 좋다)'라는 작성 방식은, '몇 개의 스레드를 열지'를 서브 에이전트 자체의 판단에 맡겨버린다. 서브 에이전트는 무거운 작업을 시작할 때, 그 정수 하나만을 인정하며, 프롬프트의 다른 곳에 쓰인 스레드 수에는 전혀 따르지 않는다.
한 바퀴의 작업을 시작하기 전에, 메인 에이전트는 먼저 records/ 아래에 스케줄링 기록을 작성한다: 과제, 출구, 이번 주기의 모든 작업, 각 작업이 무엇에 의존하는지, 입력이 갖춰졌는지, 몇 번째 배치에 속하는지. 그 후, 각 배치가 돌아올 때마다 이 표로 돌아와 '이번 주기에서 닫기 / 다음 주기에서 열기 / 하지 않음'을 판별한다. 단 하나의 조항도 침묵해서는 안 된다.
이 파일이야말로 내가 말한 '각 항목을 앞으로 나아갈 때마다 그것을 읽고 생각하는 것'이다. 메인 에이전트의 컨텍스트는 압축되지만, 스케줄링 파일은 압축되지 않는다.
여러 메인 에이전트가 동시에 하나의 리포지토리에서 작업할 경우, 세 가지 것에서 충돌이 발생한다: 같은 파일, 같은 워크 트리, 같은 머신.
기사 초반의 그 대화('***작업은 당신의 것입니까?') 뒤에 숨겨진 것은 다음 것이다:
- 세션끼리 직접 이야기하기:
ListAgents로 같은 머신의 다른 세션을 나열하고, SendMessage로 보낸다. 규칙은 main-agent.md의 '한 바퀴를 어떻게 열고 닫을지' 제9조에 쓰여 있다: 타인의 커밋되지 않은 변경이 자신의 방해 요소가 되면, 메시지를 보내 협의한다. 전달되지 않으면 사람에게 전갈을 부탁한다. -
디렉토리에는 주인이 있다: scripts/hosts/work-directory-ownership.sh가 작업 루트 아래의 각 디렉토리에 소유권 파일(세션 번호, 생성 일시, 용도)을 작성한다. 클린업으로 지울 수 있는 것은, 소유권이 자신에게 있고, 어떤 프로세스도 열고 있지 않은 것뿐이다. 다른 세션의 것은 하나하나 나열하여 사람에게 건네주고, 지우지 않는다. '이 디렉토리는 당신의 것입니까?'는 바로 이것을 묻는 것이다. -
머지는 순서 대기: scripts/gate/lib_workspace_lock.py이다. 메인 워크스페이스에 패치를 적용하고, 그 위에서 빌드 및 검증할 때 잠금(Lock)을 가진다. 잠금에 기록하는 것은 프로세스 번호뿐만 아니라, 프로세스가 시작된 시간도 포함한다:
pid=, start=(/proc/<pid>/stat의 제 22 필드, 프로세스 번호 재사용 방지), session=, command=, since=
① 기록이 비어 있음: 자신의 플레이스홀더를 작성하고, 잠금을 얻는다.
② 보유자가 아직 살아있음 (프로세스가 있고, start가 일치): 순서 대기하며, 60초마다 '세션 … , 프로세스 … 가 점유 중'이라고 한마디 보고한다.
...
하지만 프로세스 번호만 비교하는 것으로는 충분하지 않다. 머신이 재부팅되어 프로세스 번호가 재사용되면, '죽은 잠금(Dead Lock)'이 '영원한 점유'로 바뀌어 버린다.
사양 리포지토리 singlefs-ai-sop
이 리포지토리에는 두 개의 별도 프로젝트가 있다. 이 리포지토리 내의 .claude/singlefs-ai-sop/는 심볼릭 링크가 아닌 복사본이며, 버전 스탬프인 .singlefs-ai-sop-version을 가지고 있다. 게이트의 첫 번째 단계는 이를 복사본의 VERSION과 비교하고, 일치하지 않으면 빨간색으로 표시된다. 이는 '규칙이 바뀌었다. 먼저 읽고 실행하라'는 의미이다.
이 리포지토리의 세션에서 상류(upstream)를 변경할 때는 파일에 내용만 채우면 된다: 버전 번호를 올리지 않고, 매니페스트의 해시를 바꾸지 않으며, 버전 번호가 포함된 변경 항목을 작성하지 않고, 커밋하지 않는다. 이 작업들은 릴리스를 담당하는 세션의 역할이다. 내용을 채우기 전에 상류의 git status를 확인하여 누군가 릴리스 중이라면 커밋이 끝날 때까지 기다리고, 내용 채우기가 끝나면 릴리스 담당 세션에게 어느 단락을 채웠는지 알려준다. 릴리스 후에는 이 리포지토리가 rsync로 다시 복사하고, diff -rq로 일치 여부를 확인한 다음, 버전 스탬프를 업데이트한다.
이것이 '상류가 검증하여 임시 저장(릴리스)하고, 그 후에 하류에 동기화하는' 과정이다. 두 세션 사이는 메시지로 인계하며, 컨텍스트의 기억에 의존하지 않는다.
정보의 희석은 막을 수 없다. 하나의 세션이 수십만 토큰의 컨텍스트까지 실행되고 한 번 압축되면, 초반 몇 줄의 규칙은 단지 한 문장의 요약만 남는다. 다시 압축되면 그 요약마저 사라진다.
그래서 발상은 '에이전트에게 기억시키기'가 아니라, 중요한 노드에서 신호를 재설정하는 것이다. Claude Code의 훅(hook)은 단순한 훅이지만, 바로 이것을 할 수 있다: 매번 도구 호출 전후, 세션 시작 시, 압축 후, 서브 에이전트 종료 시에 스크립트를 삽입할 수 있다. 스크립트의 출력은 컨텍스트로 에이전트에게 돌아가기 때문에, 반환된 텍스트를 스크립트로 컨텍스트에 주입할 수 있다.
singlefs에서 현재 등록된 hook:
| 이벤트 | 개수 | 적용 대상 |
|---|
| PreToolUse | 12 | 디스패치, 메시지 전송, Bash, 파일 쓰기, 다이얼로그, 타이머, 서브 에이전트 반환 |
| ... | | |
| 몇 가지 요점: | | |
- 압축 후:
session-start.sh는 compact 시점에 세 가지를 보완한다: 아직 실행 중인 서브 에이전트가 있는지, 워크스페이스에 커밋되지 않은 것은 무엇인지, 이 작업 상태가 어느 기록에 적혀 있는지. 압축 요약에서 가장 손실되기 쉬운 것이 바로 이 세 가지이다. 이것만 남아있다면, 작업을 놓치더라도 다시 찾아낼 수 있다. -
- 세션 시작 시: 동일한 스크립트가 커널 로그를 읽어 최근 12시간 동안 OOM(Out Of Memory)으로 종료된 프로세스를 나열한다. 머신 전체의 OOM과 cgroup 제한 내에서 종료된 것을 구분하여 나열한다. 이는 과거에, 머신이 OOM되어 세션이 다운되고 메인 에이전트가 재시작되었음에도 불구하고, 커널 로그를 확인하지 않고 같은 단계를 그대로 재디스패치하여 두 번째 OOM을 일으킨 적이 있기 때문이다. -
- 인계는 메인 에이전트를 거치지 않게 하여 오염 방지: 서브 에이전트가 도중에 멈추더라도, 메인 에이전트에게 그 대화를 읽히고 다시 말하게 하지 않는다.
scripts/agents/agent-handover.py가 세션 기록에서 디스패치 프롬프트, 작성된 파일, 실행한 명령어와 그 출력을 기계적으로 추출하여 새로 디스패치하는 곳에 직접 전달한다. 메인 에이전트는 읽지 않는다. 정보는 노드를 하나 거칠 때마다 한 번 감쇠하기 때문이다. 그러니 경유하는 노드를 하나 줄이면 된다. -
- 반환 후 계속 진행시키지 않기:
continuation-guard.sh는 이미 반환한 서브 에이전트에게 메시지를 보내는 것을 거부한다. 작업을 추가하고 싶다면 새로 디스패치하고, 프롬프트에서 그 보고서를 가리킨다. 하나의 서브 에이전트를 반복적으로 계속 진행시키면, 컨텍스트가 패치의 층만 쌓일 뿐, 결국 아무도 그것이 무엇을 하는지 설명할 수 없게 된다.
규칙 파일 자체에도 약간의 장치가 있다: .claude/rules/에는 어떻게 할지만 쓰고, 왜인지/경위는 쓰지 않는다. '왜'인지는 records/에 쓰고, 링크 옆에
이 부분이 현재 가장 위험도가 높은 지점이다. 여러 에이전트에게 자유롭게 스크립트를 실행하게 두면, 머신은 언제든지 OOM(Out Of Memory) 상태에 빠질 수 있다. 이것이 가장 무섭다. 오랜 시간을 들인 성과가 흔적도 없이 사라질 수 있기 때문이다. 그렇다고 각 스크립트의 리소스 사용량을 제한하면, 에이전트는 본능적으로 최소한만 사용하려 하여 효율성이 심각하게 떨어지게 된다.
그렇다면 이 문제를 어떻게 해결할까?
에이전트가 마음대로 Bash를 쓰도록 두지 않고, Bash의 각 명령을 실행하기 전에 먼저 읽어본다.
무거운 테스트 가드(Heavy Test Guard) .claude/hooks/heavy-test-guard.sh
: 무거운 테스트(크래시 포인트 리플레이, QEMU, herd7, 변이표 전체 건, 모든 양의 cargo test, 게이트 한 바퀴 분량 등...)로 인식되면, 서브 에이전트가 발행한 것은 일괄 거부한다. 메인 에이전트 역시 SINGLEFS_HEAVY_TESTS=commit 또는 =user-request 접두사가 없으면 거부한다.
# 서브 에이전트가 실행: 거부
cargo test --workspace
# 메인 에이전트가 접두사 없음: 거부
...
초기 디버깅 과정에서 세 번이나 우회된 전례가 있어, 현재는 파일 맨 앞에 기록해 두고 소개하겠다. 꽤 재미있으니 보자:
- 하루 동안 몇몇 구현자들이 각자의 복제본으로
cargo test --all을 실행하여, 풀 부하로 한 번에 네다섯 시간이 걸렸다. 그래서 결정했다: 서브 에이전트는 일괄 실행 금지.
- 어느 구현자가 검증의 연쇄를
/tmp 아래 스크립트로 작성하여 그것을 Bash로 실행시켰다. 가드에는 '스크립트를 하나 실행한다'는 것만 보였고, 통과해 버렸다. 그래서: 명령어 위치에서 실행되는 스크립트 파일은 읽어서 한 줄씩 판별한다.
- 읽도록 하니 또 사고가 났다: 큰따옴표 안의 이스케이프되지 않은 백틱이 어떤
.rs 파일을 명령어 치환으로 처리하게 만들었고, .rs 파일이 셸 스크립트로 로드되었으며, 그 안에 있는 경로 같은 단어까지 다시 스크립트로 읽히면서, 6단계에 걸쳐 재귀적으로 하나의 명령 판별이 100초가 지나도 끝나지 않았다. 그래서: 동일한 스크립트는 한 번의 판별에서 한 번만 읽는다.
에이전트들이 일부러 우회하려 한 것은 아니다. 그저 작업을 끝내고 싶었을 뿐이며, '스크립트를 작성하고 실행하는 것'이 그들에게 가장 자연스러운 방식이었다. 그래서 프롬프트로 '실행하지 말라'고 해도 지키지 못했고, 실행 전에 제한할 수밖에 없었다.
Bash를 시작할 때마다 선언하는 것도 의무화했다. 범위 선언 가드(Scope Declaration Guard) run-scope-guard.sh
: 어떤 검증을 시작하든, 명령어 맨 앞에 다음 단계를 붙여야 한다:
SINGLEFS_RUN_SCOPE='종류=items; 대상=게이트 doc-decisions.sh의 decision-items-sync 셀; 범위=1 셀; 이유=이 배치에서는 두 가지 결정의 세부 항목만 변경했으므로, 세부 항목 목록을 재생성하는 것만으로 충분하다' \
bash .claude/gate.d/doc-decisions.sh --write --check decision-items-sync
네 개의 칸: 종류 (전체 한 바퀴 / 몇 가지 지정 / 경량 / 하나의 타겟), 대상 (무엇을 실행할지), 범위,
이유.
종류와 명령어의 형태가 맞지 않으면 거부(items라고 선언했는데 명령은 전체 한 바퀴)하고, '이유'가 12자를 넘지 않으면 거부하며, 서브 에이전트가 전체 한 바퀴를 시작하면 일괄 거부한다. 이 가드는 어느 날 세 개의 검증 드라이버가 모두 풀세트를 재실행하여, 머지 후의 검증이 매번 212개의 테스트 바이너리를 전부 실행했기 때문에 생겨났다. 그 배치 패치는 두 개의 크레이트만 건드렸는데도 말이다.
그 이후로는, 에이전트에게 실행 전에 자신의 언어로 왜인지를 한 번 말하게 하는 것이 사후에 '너무 많이 실행했다'고 지적하는 것보다 훨씬 효과적이었다.
그 외에도, 나중의 실습을 통해 생겨났으며 이 프로젝트에서만 사용하고 있는, 아직 최적화 중인 몇 가지가 있다:
write-guard.sh는 쓰기 범위표에 따라 에이전트 종류별로 쓸 수 있는 파일을 제한한다. timer-only-remind-guard.sh는 remind.sh 이외의 모든 타이머 작성 방식을 거부한다 (sleep 300; echo, crontab, CronCreate)
(모든 거부). bash-command-detector.sh는 타임아웃이 없는 대기 루프나, 백그라운드 안에 또 다른 백그라운드를 넣는 방식 등을 감지한다. 이 단계가 스케줄링 전체의 핵심이며, 네 가지 계층으로 나뉜다.
상류 사양인 preflight-discipline.md는 각 스크립트 파일의 맨 앞에 두 종류의 조건을 작성하도록 요구한다:
# admission: inputs-changed crates/ scripts/crash/ env:SINGLEFS_THREAD_CAP
# run-condition: command bash git rsync python3
# run-condition: single-instance
admission은 "이번 호출에 의미가 있는가"에 답한다 (이전 성공 이후 입력값이 변경되지 않았다면 재실행할 필요가 없다). run-condition은 "환경이 이를 감당할 수 있는가"에 답한다 (도구가 없거나, 장치가 없거나, 이미 인스턴스가 실행 중이다). 스크립트는 가장 먼저 preflight를 호출하고, 조건이 충족되지 않으면 충족되지 않은 조건과 종료 코드를 출력하며, 종료 코드 78로 빠져나가 아무것도 실행하지 않는다.
78이라는 숫자는 이 한 종류의 거부에만 사용된다. 호출 측은 78을 보면 "실행되지 않았다"고 알 수 있어, "실행되어 빨간색이 되었다"고 오해하는 일이 없다.
.claude/hooks/host-resources-reminder.sh와 PreToolUse[Bash]에 걸려 있다. 어떤 명령어가 무거운 작업을 하거나 GPU를 구동한다고 인식하면, 먼저 scripts/hosts/host-resources.py -- <이 명령어>를 한 번 실행하고, 그 결과를 additionalContext로 간주하여 해당 명령어를 내보낸 에이전트에게 반환한다:
- 각 머신: 코어 수, 현재 사용 중인 코어,
loadavg, 그리고 몇 개의 스레드를 더 열 수 있는지 - 각 머신: 사용 가능한 메모리(MemAvailable에서, 메모리 래퍼의 장부에 '허용되었지만 아직 예측 피크까지 늘어나지 않은' 부분을 제외한 값)
- 각 GPU: 총 VRAM, 여유 공간, 카드에 어떤 계산 프로세스가 있는가. 반납 서비스가 점유하고 있는 부분은 "반납 서비스가 N MiB를 점유"라고만 보고하며, 서비스 이름은 보고하지 않는다.
- 이 명령어의 이력: 이전 피크, 얼마나 실행되었는지
- 결론: "실행 가능: 이 머신에서", 또는 "실행 불가능"과 각 머신별로 얼마나 부족한지
이 훅(hook)은 결코 거부하지 않으며, 단지 상기시켜 줄 뿐이다. 이것이 두 번째 단계의 "검사 결과는 프롬프트로 메인 에이전트에게 반환되는 것"에 해당하며, 에이전트가 Enter를 누르기 전에 컨텍스트에 자원표 한 장이 추가된다. 보고서를 읽는 스크립트에는 25초 제한이 있으며(2대를 병렬로 읽을 경우, 각 대당 최대 12초), 읽지 못하면 솔직하게 "읽을 수 없었다"고 작성하고 명령어는 평소대로 통과시킨다.
scripts/hosts/run-with-memory-cap.sh는 스케줄링 전체에서 가장 무거운 스크립트이다(셀프 테스트 포함하여 2,800라인). 서브 에이전트가 cargo test나 실험 바이너리를 실행할 때는 반드시 이것을 거친다. 통과시키지 않은 것은 중급 테스트 가드에 의해 거부된다.
세 단계로 나뉜다:
① 상한선: systemd의 임시 scope에 넣기
MemoryMax=<상한> MemorySwapMax=0 OOMPolicy=stop
상한선에 도달해도 종료되는 프로세스는 이 scope 내부의 프로세스뿐이다.
...
여유 공간 20G는 실측을 통해 결정되었다: 머신 전체가 60.1 GiB이고, slice 외부에서 회수할 수 없는 것들의 합계가 13.4 GiB(로컬 모델 서비스, 각 세션, 커널)이므로, 20G는 그것보다 6.6 GiB 더 많아 여유 공간으로 남겨둔 것이다.
그리고 몇 가지 놓쳤던 디테일:
"사용 중"에서 페이지 캐시를 제외한다. 종료된 scope의 페이지 캐시는 slice에 할당되며, 계속해서 slice의 memory.current에...
에 기록된다. 실제 측정 결과, scope 내에서 120 MiB를 쓰고 종료한 후에도 slice에는 131 MiB가 기록되어 있었으며, 그중 126 MiB는 회수 가능한 페이지 캐시였다. 빼내지 않으면 몇 번 돌고 난 후에 아무것도 넣을 수 없게 된다.
- '이 하나의 필요량'은 선언된 상한이 아니라 히스토리의 피크로 본다. 각 명령어 종료 후, 래퍼(wrapper)는 자신의 cgroup의
memory.peak를 읽어 피크 표에 기록한다. 상한 24G, 히스토리 피크 1.8G인 명령어는 장부상 1.8G만 기록한다. 이렇게 하지 않으면, 큰 상한을 가진 몇몇 명령어가 총 상한을 채워버려 서로 불필요하게 기다리게 된다.
- 히스토리의 피크로 넣은 것이 먼저 죽는다. 래퍼는 자신에게
oom_score_adj를 부여한다: 무제한으로 확보 가능한 변이에는 1000, 피크 표에 넣은 것에는 500, 상한으로 넣은 것에는 0. 만약 예측이 너무 적어서 slice가 가득 차면, 커널은 '도박을 한' 것부터 죽인다. 밀려난 키는 '천장에 부딪혔다'고 기록되며, 다음번에는 상한으로 순서 대기한다.
- 아무리 기다려도 안 되는 것은 기다리지 않는다. 장부상에서 돌아가는 것이 없고, 게다가 이 하나의 필요량과 회수할 수 없는 것의 합이 이미 총 상한을 초과했다면, 누군가 끝나서 자리를 내어줄 리 없다. 그 자리에서 종료하고 한 시간이나 헛되이 줄 서지 않는다.
- 순서 대기하는 김에 두 번째 기회도 본다. 처음에 순서 대기에 들어간 순간과 최종적으로 포기하기 직전에, 두 번째 기회가 지금 수용될 수 있는지 확인한다. 수용 가능하다면, 바로 사용할 수 있는 명령어를 한 줄 출력한다:
→ 두 번째 기회라면 지금 수용 가능 (남은 공간 X, 필요 Y): bash scripts/hosts/multi-host-run.sh --peer --memory-cap <상한> --label <이름> -- <이 명령어>로 전환한다
동시에 알림 기록에도 한 건을 작성하고, 워치독(watchdog)이 그것을 읽어 메인 에이전트를 깨운다. 자동으로 옮기지 않는다: 두 기회의 결론은 기준이 다를 수 있어, 섞으면 어떤 숫자가 어느 쪽에서 돌아갔는지 구별할 수 없게 된다. 옮길지는 메인 에이전트가 판단한다.
종료 코드는 모두 독자적으로 설계한 상태 코드이며, 다른 명령어와 충돌하지 않는다:
| 코드 | 의미 |
|---|
| 245 | VRAM 부족 (명령어 시작부터 종료까지 커널 로그에 NV_ERR_NO_MEMORY가 기록됨) |
| ... | |
250과 254의 구별은 scope 자체의 memory.events만 본다: 자신의 oom 카운트가 0이 아니면 자신의 천장에 부딪힌 것이다. 0인데 oom_kill이 0이 아니면, slice가 가득 차서 커널이 slice 안에서 그것을 선택했다는 의미이다. | |
효과는 어떠한가? 이 세션 초반에 hook이 보고한 바에 따르면: 최근 12시간 동안 커널이 메모리 부족으로 죽인 프로세스는 24개였고, 24개 모두 cgroup 상한 내에서 발생했으며, 머신 전체가 떨어진 적은 한 번도 없었다. OOM은 여전히 일어나지만, '머신 재부팅, 진행 손실'에서 '하나의 명령어가 250으로 끝나고, 상한을 바꿔서 다시 실행'으로 바뀌었다.
스레드 수는 그 자리에서 계산하지 않는다. 리포지토리 전체에서 각 머신의 무거운 작업용 스레드 수를 등록하는 곳은 한 군데뿐이다, .claude/heavy-thread-default:
31 # 모든 무거운 작업은 기본적으로 각 머신 31 스레드. 두 기회라면 각각 31, 합계 62. 인수가 있으면 인수(argument)를 따른다. 머신이 바빠도 31 고정, 바쁨은 보지 않는다
왜 '머신이 바빠도 31 고정'일까? 각 에이전트에게 바쁜 정도를 보고 스레드 수를 결정하게 하면, 결과적으로 모두가 아주 조금만 사용하고 무거운 작업이 심하게 느려지기 때문이다. 바쁜지 아닌지는 메모리 래퍼와 순서 대기에 맡기고, 스레드 수는 그냥 상수(constant)로 한다.
통일 디스패처 scripts/hosts/host-dispatch.py:
하나의 명령어를 어떤 머신에 내보내고, 얼마나 많은 메모리 상한을 줄지. 전체적으로 지켜야 하는 것은 단 하나: 어떤 머신도 메모리나 VRAM으로 OOM(Out Of Memory) 시키지 않는 것.
⓪ 긴 작업은 단일 머신에 할당하지 않는다: 기록 소요 시간(또는 호출 측이 제시한 예상 경과 시간)이 2시간을 초과하고, 출력이 한 대의 장치만 사용하는 경우 ⇒ 출력하지 않고 244로 종료.
출구: 2대로 샤딩 방식을 변경하거나, 스크립트 파일 맨 앞에 긴 작업 선언(샤딩, 재개, 멀티 머신 세 줄)을 추가한다.
① 머신: 이 머신은 항상 존재한다. 두 번째 장치는 멀티 머신 설정에서 읽는다(리포지토리에 넣지 않는다). 듀얼 머신 스위치가 꺼져 있으면 이 머신만 사용한다.
...
어떤 머신의 리소스가 충분한지 판정하는 것:
def judge_host(host, report, prediction, gpu, pending_memory, pending_gpu, peer_cap_bytes):
memory = report["memory"]
cap = prediction.memory_cap
...
pending_memory에 주의한다. 디스패처가 한 번 출력을 내보낸 후, 메모리 래퍼 측에서 실제로 장부에 기록될 때까지 수 초에서 수십 초가 걸린다(두 번째 장치는 먼저 트리를 복사해야 한다). 그 사이에 또 다른 것이 들어오면, 양쪽 모두 상대방을 볼 수 없어 함께 밀어 넣게 된다. 따라서 디스패처도 스스로 장부를 작성하여 '출력 후 30초 미만'인 것은 예측량으로 미리 차감해 두어야 한다.
머신 간 드라이버 scripts/hosts/multi-host-run.sh --peer:
: 결정적인 작업을 하나를 두 번째 장치로 옮겨 실행한다. 일곱 단계:
① 설정: 듀얼 머신의 스위치 ENABLE_ACROSS_MACHINES가 켜져 있지 않으면 거부된다. 몰래 이 머신에서 실행하는 쪽으로 퇴거하지 않는다.
② 상한: 설정의 PEER_MEMORY_CAP을 초과해서는 안 된다.
③ 툴체인: 두 대의 rustc -Vv, cargo -V를 한 줄씩 비교하여 다르면 거부한다. 두 대에서 빌드되는 것이 다르면 결론을 함께 내릴 수 없다.
...