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

세대, 그리고 함께 쓰는 체크아웃

~15 min · fencing, concurrency, git, coordination

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

세션이 자기 권한보다 오래 살 수 있어

채팅을 따라다니는 어댑터는 office의 자식 프로세스야. 재시작될 수도 있고, 갈아 끼워질 수도 있고, 등록이 끝났다가 같은 채팅이 나중에 다시 등록되면 뒤에 남겨질 수도 있어. 그 내내 채팅의 네이티브 id는 그대로야. 그러니 신원만으로는 지금 어댑터랑 옛 봉투를 아직 쥐고 있는 낡은 어댑터를 구분 못 해. Coop은 바로 그걸 위해 숫자 하나를 더 붙여. 세션의 세대(generation).

펜싱 토큰으로서의 세대

세션 행은 세대 1에서 시작하고, 숫자가 올라가는 곳은 두 군데야. 하나는 register가 은퇴한 세션을 다시 등록할 때야. 새 세대를 시작하면 "fences anything still acting for the old one"이라고, 코드 주석이 이유를 적어 뒀어. 다른 하나는 CLI로 일을 가져간 채팅이 관리되는 등록으로 입양될 때야. 이땐 adopt_take가 세대를 올려.

엔진이 내주는 건 전부 발급될 때의 세대가 찍혀 있어. 디스패치, 바인딩, 아웃박스 봉투. 어댑터가 요청하는 건 전부 세대를 밝혀. 수락, 거절, 보고, 배달 영수증, 상태 관찰. 그리고 수락, 거절, 보고, 노트, 배달은 전부 작은 도우미 _session을 거쳐. 이 도우미는 세대가 현재 것이 아닌 호출을 전부 거절해. "Stale or exited session generation." 상태 관찰도 따로 똑같이 비교하고. 보고는 한 걸음 더 나가서, 바인딩 자체도 이 세션과 이 세대 것이고 아직 활성이어야 해. 아키텍처의 실패 표는 이걸 담백하게 적어. 옛 세대의 보고는 충돌이고, 현재 주인의 상태는 보존돼.

울타리는 양쪽에서 작동해. 어댑터는 자기 세대 봉투만 배달하고("Host queue does not own this envelope"), 재등록하면 어댑터 파일도 새 숫자로 다시 써. 안 그러면 "the runtime would keep refusing the stale one." 엔진이 뜰 때는 cancel_fenced가 대기 봉투를 훑어. 세션의 현재 세대보다 오래된 세대가 찍혀 있으면 전부 취소하고, 이유는 fenced-generation으로 남겨. 그런 행은 절대 보낼 수 없거든. 그냥 두면 영원히 "배달 중"으로 읽히면서 등록을 붙잡고 있어. 안 읽은 답글은 대신 현재 세대로 새 리뷰가 잡혀. 입양만은 일부러 예외야. 아무도 채팅을 안 지켜보던 동안 쌓인 봉투는 새 세대로 넘어가. 여전히 그 채팅 거니까.

이게 Martin Kleppmann의 펜싱 토큰이야. 잠금을 쥔 프로세스는 멈칫할 수 있어. 모르는 새 임대가 끝나고, 깨어나서는 아무 일 없었다는 듯 써. 해법은 권리를 내줄 때마다 커지는 숫자고, 저장소가 그걸 확인해서 더 작은 숫자를 단 쓰기를 거절하는 거야. Coop의 세대가 그 숫자고 엔진이 그 저장소야. 핵심은 확인이 어디에 있느냐야. 낡은 프로세스의 착한 행실이 아니라 저장소에.

세션 둘, 체크아웃 하나

아빠는 저장소 하나에서 세션 여러 개를 서로 다른 항목으로 늘 같이 돌려. 2026-09-30(todo #285, 커밋 43ddac9)에 체크아웃 하나에서 새 Claude 채팅이 READY라고 답하고 멍하니 있었어. 탐색은 6초 만에 찾았는데, prepare가 디스패치를 거절했거든. 3분 전에 #284를 배정받은 Codex 세션이 같은 체크아웃을 쓰기 범위로 쥐고 있었고, 그 실패는 Telegram에만 닿았어. 저장소 하나에 순서대로 띄운 실행은 첫 번째 뒤로 전부 같은 식으로 실패했어.

고친 방법은 규칙을 뒤집는 거였어. 세션 사이에 쓰기 범위가 겹치면 거절 말고 공개. 수락할 때 shared_checkout이 다른 세션을 훑어. 살아 있는 디스패치의 쓰기 범위가 이번 것과 겹치면(어느 한쪽 경로가 다른 쪽 안에 들어가면) 전부 나열해. 벤더, 행위자, 상태, 범위, 항목 제목까지. 독스트링이 정책 전부야. "A disclosure, not a lock." 같은 세션 같은 세대의 디스패치는 건너뛰어. 네이티브 주인 하나가 자기 자신과 부딪힐 일은 없으니까. 그리고 같은 항목의 중복 디스패치는 여전히 거절돼. 목록이 비어 있지 않으면 작업 봉투에 이 지시가 붙어.

"Other sessions are working in this same checkout right now (shared_checkout lists them and their tasks): never revert, stash, reset, clean or commit their changes; stage and commit only files you changed, by explicit path; if a file you need is also being changed by another session, ask Dad before touching it."

진짜 보호 장치는 원래 자리에 그대로 있어. 가족의 git 규율, 그리고 실행자가 쥐는 잠금. Firelink의 배포 잠금 같은 것.

READY에서 하염없이 기다리는 채팅은 없어

같은 커밋이 새 봉투 종류를 하나 들였어. notice. 채팅이 연결된 뒤에 배정이 실패하면 그 채팅한테 알려줘. Coop could not hand this chat its task (…): <reason> Nothing was claimed, and Dad has been notified. Wait for Dad's instruction; do not start the task on your own. 어댑터는 이걸 A notice from cwkCoop; it asks for nothing but an acknowledgement.로 감싸. 알림은 아무것도 빚지지 않아. 그래서 등록을 붙잡지도 않고, 등록이 은퇴할 때 아직 대기 중인 알림은 취소돼.

낡을 수 있는 건 울타리를 치고, 못 막는 건 공개해. 세대는 과거가 현재에 손대지 못하게 막아. 공개는 지금 있는 두 세션이 혼자인 척하지 않고 작업 트리 하나를 나눠 쓰게 해줘. 체크아웃에 잠금을 거는 게 짜기는 더 쉬웠겠지. 근데 그건 아빠가 실제로 일하는 방식엔 안 맞았을 거야.

Code

발췌(일부 생략, 위치 안내 주석 추가): 세대를 확인하는 곳·python
# engine.py: accept, decline, report, notes and deliveries all pass through this
def _session(self,conn,sid,generation):
    session=self.row(conn,'engine_sessions',sid)
    if session['generation'] != generation or session['status'] == 'exited':
        raise Conflict('Stale or exited session generation')
    return session

# engine.py, Engine.cancel_fenced (run when the engine starts): an older
# generation's pending envelope can never be sent, so it is cancelled
cancelled = conn.execute('''UPDATE engine_outbox SET state='cancelled',receipt=? WHERE state='pending' AND session_id IS NOT NULL
    AND generation < (SELECT generation FROM engine_sessions s WHERE s.id=engine_outbox.session_id)''',
    (dump({'not_delivered': {'reason': 'fenced-generation'}}),)).rowcount

# adapters/host.py, Coordinator.process: an adapter delivers only its own generation
sid,generation=self.session['id'],self.session['generation']
if (outbox['session_id'],outbox['generation'])!=(sid,generation):
    raise Conflict('Host queue does not own this envelope')
실행 가능: Engine.shared_checkout이 두 디스패치의 쓰기 의도에 거는 겹침 검사·python
from pathlib import Path

def overlaps(mine, theirs):
    # The same expression Engine.shared_checkout uses (Python 3.9+).
    return any(Path(a).is_relative_to(b) or Path(b).is_relative_to(a) for a in mine for b in theirs)

repo = '/work/myrepo'
cases = {
    'same checkout':      ([repo], [repo]),
    'subdirectory of it': ([repo], [repo + '/docs']),
    'sibling repository': ([repo], ['/work/myrepo-site']),
    'investigation only': ([repo], []),
}
for label, (mine, theirs) in cases.items():
    print(f'{label:20} {overlaps(mine, theirs)}')
# same checkout        True
# subdirectory of it   True
# sibling repository   False   (a path test, not a string prefix test)
# investigation only   False   (no write intent, nothing to disclose)

External links

Exercise

두 단계야. 먼저 연습용 저장소에서 두 "세션"인 척 수정해봐(터미널 둘이든, 에이전트 둘이든, 그냥 파일 묶음 둘이든). 한 세션 작업을 git add -A로 커밋하고, 그 커밋이 뭘 쓸어 담았는지 봐. 그다음 명시적인 경로로 스테이징해서 다시 해봐. 둘째, 네가 가진 작은 워커 레지스트리에 세대 카운터를 달아. 워커를 다시 등록하면 숫자가 올라가고, 워커의 쓰기엔 등록 때 받은 숫자가 실리고, 저장소는 더 작은 숫자를 단 쓰기를 거절해. 작업 중인 워커를 일시정지시켜(터미널에서 Ctrl-Z, 아니면 그 pid에 kill -STOP). 같은 이름으로 새 인스턴스를 등록하고, 옛 프로세스를 다시 깨워(fg, 아니면 kill -CONT). 그리고 옛 프로세스가 쓰기를 시도하게 놔둬봐.
Hint
레지스트리 확인은 비교 한 번이야. 근데 그걸 쓰기가 떨어지는 곳(데이터베이스 행, API 핸들러)에 둬. 워커 안에 두면 안 돼. 낡은 워커는 자기가 낡은 줄 몰라. 토큰이 있는 이유가 통째로 그거야.

Progress

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

댓글 0

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

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