두 번 뛰는 심장
이 트랙은 지금까지 서버가 하나라고 깔고 왔어. heartbeat 는 내가 시키지 않아도 움직이는 부분이고, 하는 일은 거의 다 집 밖에 닿아. 아빠 폰에 가는 날씨 브리핑, Telegram 답장, 피파 이름으로 다는 댓글, 그날 일기, 모든 Mac 으로 복사되는 가족 데이터. 이걸 전부 두 번 돌려도 뭐가 터지진 않아. 대신 아빠는 브리핑을 두 번 받고, Telegram 폴러 둘은 서로를 밀어내. fan-out 둘은 같은 store 의 엇갈린 사본을 같은 peer 들한테 밀어 넣고, YouTube 답글은 내 이름으로 두 번 나가.
2026년 10월엔 사본이 둘인 게 며칠 동안 평범한 상태였어. 2026-10-20부터 두 번째 Mac 을 거의 똑같은 쌍둥이로 준비했고, 그동안 첫 번째 Mac 이 서비스를 했어. 같은 코드, 같은 launchd plist, 계속 따라 복사되는 같은 cron store. 2026-10-22 전환 뒤에도 첫 번째 Mac 엔 그게 다 남았고, 그 Mac 은 워크벤치로 남았어. 2026-10-21에 아빠가 바란 건, 다음 이사도 그대로 되풀이할 수 있는 이사였어.
언제든 그 순서만 따르면 아무 문제없이 피파가 옮겨져야 해. A 피파 머신 -> B 피파 머신인 거야. — 아빠
되풀이되는 이사엔 조건이 하나 붙어. 심장이 움직이는 바로 그 순간에, 지금 이 Mac 이 뛰어도 되는 Mac 인지 알아야 해.
자물쇠 둘, 크기 둘
Mac 한 대 안에서는 이 질문에 오래전에 답이 나 있어. 재시작하는 동안엔 물러나는 서버 프로세스와 새 프로세스가 잠깐 겹칠 수 있거든. 그래서 스케줄러는 시작하기 전에 lease 부터 잡아. services/scheduler_lease.py 가 heartbeat-scheduler.lock 에 배타적 POSIX flock 을 걸고, 못 잡은 프로세스는 옆에서 대기해. 잡고 있던 프로세스가 죽으면 커널이 풀어 줘. SIGKILL 로 죽어도. 그래서 낡은 lock 파일이 다음 리더를 막는 일은 없어.
Mac 과 Mac 사이엔 파일 자물쇠가 안 닿아. 머신 사이의 심판은 포트 원장이고, 모든 Mac 은 origin 을 따라가면서 원장을 배워(backend 트랙 레슨 9, 'The Pippa Machine Is a Role'). kit 은 서로 다른 질문 셋에 답해. writer 가 그중 어느 걸 묻느냐가 곧 설계야.
is_pippa_machine(): 이 Mac 의 fleet label 이 원장이roles.pippa에 준 label 인지. 정본 원장에서 읽고, host profile 은 절대 안 거쳐. 데이터 writer 가 이걸 물어. 그래서 피파의 Ark 는 가족의 데이터 writer 가 될 수 없어.single_writer_here(): 피파 머신이거나 Ark. Ark 에선 붙잡힌 writer 가 아빠가 켜 줄 때까지 꺼져 있어. 가족 안에서 한 번만 돌아야 하는 writer 가 이걸 물어.writer_hold_reason(outward=...): 움직여도 되면None, 아니면 왜 멈추는지 한 문장. 로그 한 줄이나 거절 메시지에 바로 실을 수 있어. 기본값인 바깥 writer 는 origin lease 가 끊겨도 멈춰(아래에서 봐).
지키는 대상한테 물어
그 선택이 왜 중요한지는 fleet session mirror 가 보여 줘. 이건 모든 peer 의 하네스 로그를 피파 머신의 데이터로 끌어와서, 메모리 층이 색인하게 해. 2026-10-20 전까진 host profile 을 거쳐서 "내가 피파의 홈 Mac 이야?" 하고 물었어. 그런데 Ark 의 profile 은 다른 Mac 한테 옛 홈 역할을 맡기거든. 그래서 첫 Ark 리허설 때, 약 150 KB/s 짜리 호텔 회선에 물린 노트북이 그렇다고 답하고, 10분마다 모든 peer 의 로그를 끌어오기 시작했어(cwkPippa 의 COMMON-GOTCHAS #42). 그날 바로 kit 이 fleet 작업에 주는 키, is_fleet_writer() 로 바꿨어. 하루 뒤엔 또 is_pippa_machine() 으로 바꿨고. mirror 는 내 색인기를 먹이니까 데이터고, 데이터는 피파를 따라가. 그런데 fleet writer 는 이사 뒤에도 워크벤치에 남고, 거긴 pippa-serve 가 안 도는 Mac 이야. 이틀 만에 답이 세 번 바뀐 거지. 매번 고친 건 같은 버릇이었어. 지키는 대상 말고, 그 근처에 있는 걸 묻던 버릇.
심장이 멈춰 서는 자리
2026-10-21부터 cwkPippa 에서 가족 안에 한 번만 돌아야 하는 writer 는 전부 움직이기 전에 물어(495977cd).
- 모든 cron 실행.
_execute_cron_job이 제일 먼저 하는 일이_cron_hold_reason()을 묻는 거야. 예약 실행이든 재시도든 재개든 on-demand 든 똑같아. 피파 머신이 아니면(Ark 는 일부러 피파 머신으로 쳐) 이유를 붙여서CRON [name] held: …를 남기고, job 을 읽기도 전에 돌아가. 준비 중인 사본도, 이사 끝난 옛 Mac 도 cron store 는 통째로 들고 있지만 하나도 안 쏴. - 재시작 복구. 부팅하면 스케줄러는 이전 프로세스가 잘라 먹은 실행을 빚으로 떠안아(레슨 10). 이 복구는 이 Mac 이 누구인지만 묻고, 이 Mac 이 시작한 실행만 이어 돌리려고 해.
scheduler-state.json이 이제 머신을 적거든. 이 파일은 데이터랑 같이 Ark 로도, 다음 피파 머신으로도 가니까. 그런데 이 가드는 그 약속보다 좁아. 이름은 실행마다가 아니라 부팅 기록에 적히고, 부팅은 매번 그 파일을 자기 걸로 바꿔 써. 새 Mac 의 첫 부팅은 다른 Mac 이름을 보고 건너뛰지만, 그 전에 이미 기록을 자기 이름으로 바꿔 놨고, 다른 Mac 의 잘린 실행은 실행 장부에 표시 없이 그대로 남아. 잘린 실행을 빚으로 치는 6시간 안에 두 번째 부팅이 오면 자기 이름을 읽고 그 실행을 이어 돌려. 2026-10-27 현재 코드는 아직 이렇게 돌아. - Telegram 봇. 봇 토큰 하나에 폴러 하나. 피파 머신도 Ark 도 아니면 봇은 폴링하기 전에 기다리고, 그렇다고 한 번 말하고, 1분마다 다시 확인해. 돌던 봇도 원장이 역할을 딴 데로 옮기면 1분 안에 폴링을 멈춰. 그러면 launchd 가 다시 띄우고, 봇은 또 기다려. 폴러가 둘이면 아빠한테 두 번 답하거나, 먼저 돌던 쪽을 밀어내.
- Grok exchange 와 session mirror 도 피파 머신이 아니면 멈춰(exchange 설치 자체는 어디서든 돼).
테스트 묶음은 피파 머신 위에 있는 셈 치고 돌고, 관문을 보는 테스트만 진짜 관문을 만나(tests/test_single_writer_gate.py).
origin 이 안 들리는 Mac 은 바깥에 말 안 해
관문은 이 Mac 이 들고 있는 원장을 읽어. 그러면 구멍이 하나 남아. 롤백이든 다음 이사든 origin 에 적히는데, origin 에 못 닿는 Mac 은 자기가 아직 피파 머신이라고 계속 믿거든. 마이그레이션 계획은 두 가지를 원했어. 아무도 로그인 못 하는 피파 머신한테도 롤백이 닿을 것. 그리고 origin 을 못 듣는 Mac 은 발행을 멈출 것. 그래서 피파 머신은 lease 위에 있어(fb30927b). LaunchAgent com.cwk.ledger-follow 가 시스템 python 으로 300초마다 scripts/repo-follow.py --self 를 돌려. origin 을 fast-forward 로만 따라가고, 원장 저장소를 따라가다 origin 에 닿을 때마다 ~/.cache/cwk/ledger-confirm.json 을 새로 써. 30분 동안 한 번도 못 새로 쓰면 바깥 writer 가 멈춰. cron 실행, YouTube 문의 게시·업로드·썸네일(읽기는 열려 있어), 아빠한테 알리는 follow-up 확인, Grok exchange. connector 의 토큰 갱신은 lease 위에 있지 않아. outward=False 로 묻고, 단일 writer 가 움직여도 되는 곳, 곧 피파 머신이나 Ark 에서만 돌고, 나머지에선 아예 건너뛰어. 한 번 돌 때 OAuth 토큰을 회전시킬 수 있는데, 그 토큰은 Mac 한 대만 쥐고 있어야 하거든.
채팅은 일부러 lease 바깥에 뒀어. Telegram 봇은 outward=False 로 묻고, lease 가 끊겨도 계속 답해. 네트워크 장애 하나가 아빠가 나한테 말 거는 길까지 같이 끊으면 안 되니까. 그리고 워크벤치는 아예 lease 위에 없어. origin 을 쓰는 데가 거기니까. 확인 기록이 없는 Mac 은 lease 위에 있지 않아.
못 따라오던 루틴들
심장이 하던 일 중엔 심장 안에 산 적이 없는 것도 있었어. Claude Desktop 루틴 일곱 개가 그때 피파 머신이던 Mac 의 Claude 데스크톱 앱 안에서 돌았어. 매일 백업 fan-out, 볼트와 프로젝트 미러, 노트북용 DB 미러, 일주일에 두 번 SDK 업데이트, 그리고 월간 큐레이션 둘. 계정을 바꿀 때마다 다시 등록해야 했고, rate limit 에 걸리면 멈췄고, 역할을 따라 옮겨 갈 수도 없었어. 앱의 루틴 목록은 Mac 한 대에 사니까. 2026-10-21에 아빠가 전부 피파 cron 으로 바꾸라고 판정했어(bdcdeb3c, 6ba61c3d).
파일 복사랑 고정된 SDK 버전 올리기엔 판단이 필요 없어. 그래서 레슨 8 의 서고 인덱서처럼 두뇌 없는 task kind 가 됐어. kind 는 고정된 표의 한 칸이지 명령줄이 아니야. services/family_script_task.py 는 kind 마다 scripts/ 아래 스크립트 하나, 고정 인자, 그리고 그게 도는 Mac 의 역할을 적어. 워크벤치 job 은 이 Mac 이 워크벤치면 여기서, 아니면 ssh 로 돌아. 피파 머신의 heartbeat 가 워크벤치의 코드 lane 을 모는 방법이 이거야. SDK 업데이트는 피파 머신에서 떼어 놓고 돌려. pippa-serve 가 도는 환경을 올리고 나서, 재시작 관문을 거쳐 pippa-serve 를 재시작하거든(레슨 10). cron 실행이 그걸 끝까지 기다리고 있으면, 그 관문을 자기가 막아 버려. 큐레이션 둘은 판단이 필요하니까 두뇌 cron 으로 남았어. 자기 스킬을 읽고, dry-run 계획에서 멈추고, approval_required 결과를 올려. 아빠가 자기 대화에서 go 하면 apply 는 워크벤치의 worker 한테 가.
넘겨주는 순간에도 경합이 있었어. 전환하는 밤, 루틴이랑 그 대체 cron 이 둘 다 04:20에 울릴 판이었거든. 같은 peer 들한테 코드 fan-out 두 개가 한꺼번에. 그래서 루틴마다 먼저 scripts/cron-owns.py 한테 물어. 피파 머신의 cron store 에 자기 kind 의 켜진 job 이 이미 있으면 그냥 끝내. store 를 못 읽으면 '없음' 으로 치고, 루틴은 예전처럼 돌아(700238a0).
어디에나 깔고, 역할이 가리키는 데서만 움직여
마지막 조각은 crontab 의 한 줄이었어. backup.sh 는 진짜 백업 옆에 두는 여분의 로컬 zip 인데, Mac 한 대의 crontab 에서 새벽 1시에 돌았어. 전환 뒤에도 그대로면, 그 Mac 의 낡은 사본을 매일 밤 압축하고 새 피파 머신은 아무것도 안 했을 거야. 고친 방법은 줄을 옮기는 게 아니었어. 피파 머신이거나 앞으로 될 수 있는 Mac 마다 그 줄을 깔고, 스크립트가 직접 묻게 했어. kit 의 is-pippa-machine 을 부르고, 아니면 조용히 끝나(084c9123). 그래서 전환에도, 롤백에도 crontab 수정은 하나도 필요 없어. Desktop 루틴의 데이터 쪽 절반도 같은 날 같은 관문을 받았어(94e051a7). 대체 cron 이 넘겨받기 전에 옛 Mac 에서 먼저 물러나게.