레이블 하나, 작업 하나
운영 중인 Sizzle은 등록된 서비스 호스트 위의 launchd 에이전트 하나야. 이름은 com.cwk.sizzle-serve. plist는 짧은데, 키 하나하나가 다 결정이야. RunAtLoad는 에이전트가 올라갈 때 작업을 켜. KeepAlive는 작업이 무슨 이유로 끝나든 다시 켜. ThrottleInterval을 10으로 둬서, 계속 죽는 상황에서도 10초에 한 번보다 자주 다시 띄우진 않아. ProgramArguments는 cwk-sizzle conda 환경의 파이썬으로 -m backend.serve를 돌려. WorkingDirectory가 저장소 루트라서 import 경로가 곧 저장소고, 셸 프로필은 끼어들 틈이 없어. 표준 출력과 표준 오류는 가족 데이터 루트 밑 sizzle-db/logs/로 가.
값을 잘 보면 경로가 있어야 할 자리에 @@HOME@@, @@DATA_ROOT@@가 있어. launchd는 plist 값을 셸처럼 펼치지 않아. 경로에 $HOME을 적으면 그 다섯 글자가 그대로 남아. plist 안의 EnvironmentVariables 블록도 띄운 프로세스가 볼 환경을 정할 뿐이고. 경로는 전부 끝까지 풀어 적어야 하니까, 저장소에 들어 있고 모든 Mac이 같이 쓰는 plist는 템플릿일 수밖에 없지. 설치 스크립트가 자기가 도는 Mac에 맞게 렌더링하는데, 그건 이어지는 레슨에서 볼 거야. 지금 기억할 건 하나야. 저장소의 파일엔 기계 이름도 사용자 이름도 없어.
프로세스의 정체
진입점은 backend/serve.py가 전부야. uvicorn에 backend.app:app이라는 import 문자열을 넘기고, 호스트와 포트는 backend/settings.py에서 가져와. 호스트 기본값은 127.0.0.1이야. 포트 기본값은 engine_port("cwkSizzle")인데, 가족 레지스트리의 포트 원장을 읽어서 7600을 돌려줘. 둘 다 SIZZLE_HOST, SIZZLE_PORT로 덮을 수 있어. 그러면 두 번째 인스턴스가 운영 포트에 올라앉진 않아. 그래도 운영 데이터에서 떼어 놓진 못해. 데이터 디렉터리는 따로 SIZZLE_DATA_DIR로 덮어야 하고, 그게 없으면 다른 포트에 뜬 두 번째 인스턴스도 첫 번째랑 같은 저널을 열어. 테스트 스위트는 이 변수들에 아예 기대지 않아. create_app에 임시 디렉터리를 직접 넘기거든(레슨 3). workers 인자가 없으니까 프로세스 하나, 이벤트 루프 하나야.
레지스트리에서 중요한 행은, 호스트를 역할 이름으로 적으면 {'host': <role>, 'engine': 7600, 'https': 7601}이야. 엔진은 루프백 7600에서만 들어. 7601은 그 앞에 선 Tailscale Serve HTTPS 쌍둥이고, 폰이랑 다른 Mac은 이쪽으로 Sizzle에 닿아. 엔진은 레지스트리 사본을 따로 안 들고 있어. backend/family_registry.py가 벤더링된 scripts/kit_family_endpoints.py를 파일 경로로 불러와. 설치 스크립트가 돌리는 바로 그 파일이야.
그 안에 사는 것들
| 부분 | 사는 곳 | 하는 일 |
|---|---|---|
| HTTP API | backend/app.py의 라우트 | 웹 클라이언트와 아이폰 앱이 똑같이 부르는 라우트 |
| 웹 클라이언트 | mount_webui(app, frontend/dist), 맨 마지막에 마운트 | 빌드된 React 앱을 내줘. API 라우트가 먼저 등록돼서 API가 이겨 |
| 읽기 워커 | ReadingWorker, asyncio 태스크 | 대기 중인 접시를 읽어. 기본은 한 번에 두 개(SIZZLE_READING_CONCURRENCY) |
| 대화 턴 | Threads | 아빠의 질문을 cwkPippa에 보내고 답을 기록해 |
| 스테이징 업로드 | 데이터 디렉터리의 staged/ | 접시가 이름을 부르기 전에, 따로 올라온 사진을 한 장씩 맡아 둬 |
| 헬스 | GET /api/health | ok, app: "sizzle", 제품 버전으로 답해 |
Firelink가 Sizzle 선언에 따라 찔러 보는 게 이 헬스 라우트야. 기대하는 본문은 "app":"sizzle" 그대로라서, 같은 포트에서 다른 엔진이 200으로 답해도 이 검사는 실패해. 이런 선언을 왜 앱이 아니라 가족 레지스트리가 쥐는지는 firelink-quest의 one-owner-per-fact 트랙이 다뤄.
시작 순서는 계약이야
시작 코드는 FastAPI lifespan 하나뿐이고, 순서엔 다 이유가 있어.
- 미러 다시 짓기.
sizzle.open이 워커 스레드에서(asyncio.to_thread)journal.jsonl을 SQLite 미러에 재생하고, 이벤트를 몇 개 읽었는지 로그에 남겨. 미러가 다 서기 전엔 아무것도 안 내줘. - 두뇌 슬롯 선언.
brain_control.adopt_once가 어떤 작업이 슬롯을 찾기 전에 Sizzle의 기능별 슬롯을 가족 권한자에 등록해. 그래서 시작하자마자 도는 다시 읽기가 자기 등록과 엇갈릴 일이 없어. 권한자가 안 닿으면 경고만 남기고, 나중에 슬롯을 찾을 때 늦게라도 선언해. - 워커 연결.
on_reading_requested가worker.kick이 돼. 캡처, 정정, 다시 읽기 요청이 올 때마다 워커가 깨. - 워커 시작. 지금 루브릭보다 오래된 읽기를 가진 접시마다 먼저
reading.requested를 기록하고, 루프를 켠 다음 자기를 한 번 깨워. 지난 프로세스가 멈출 때 대기 중이던 일이 이걸로 다 처리돼. - 턴 이어받기.
threads.resume이 끝나지 않은 대화 턴을 하나씩 봐. 그 턴의 요청 id로 cwkPippa가 이미 답을 들고 있으면 그 답을 기록해. 아니면Interrupted by an engine restart. Ask again.을 달고 실패로 처리해. 엔진이 재시작돼서 끊겼으니 다시 물어보라는 뜻이야. 질문을 다시 보내는 일은 절대 없어.
끌 때는 거꾸로 가. 워커를 멈추고, 턴 태스크를 취소하고, 저장소를 닫아.
KeepAlive면 충분한 이유
Sizzle엔 예약 작업이 없어. 읽기 워커는 켜질 때마다 한 번, 캡처, 정정, 다시 읽기 요청이 올 때, 읽기 슬롯에 새 두뇌가 정해질 때, 그리고 읽기가 끝났는데 접시가 아직 pending일 때 깨어나. 시계를 보고 깨는 일은 없어. 그래서 엔진이 바깥에 바라는 보장은 딱 하나야. 죽으면 다시 켜 달라는 것. KeepAlive가 그 보장이고, 나머진 lifespan이 해. 기록은 저널이고 미러는 버려도 돼. 접시는 읽기가 기록되거나 실패할 때까지 미러에서 pending으로 남아 있어. 그러니 크래시로 끊긴 읽기는 다음 프로세스가 그냥 다시 읽어. 끝나지 않은 턴도 정직하게 정리돼. 크래시가 가져가는 건 날아가던 모델 호출 하나고, 기록된 건 하나도 안 잃어.
LaunchAgent 일반론은 swift-quest의 macos-citizen 트랙에 있어. 여기서 볼 건 짝이야. 한 가지만 하는 감독자, 그리고 켜질 때마다 복구하는 프로세스.