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

은퇴하고, 깨어나도, 멈추진 않아

~15 min · process-lifecycle, supervision, ownership, coordination

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

정확히 여덟 시간

2026-10-14 저녁, todo #423을 맡은 Coop 워커가 스스로 blocked를 보고했어. 아빠만 답할 수 있는 질문이 생겨서, 프롬프트 앞에 서서 아빠를 기다리기로 한 거야. UTC 20:10에 조용해졌어. 다음 날 아침 UTC 04:10, 정확히 여덟 시간 만에 사라졌어. claude.ai에는 "Remote Control disconnected"가 떴고 다시는 안 돌아왔어. 같은 초에 Coop 어댑터는 채팅이 닫혔고 등록은 남겨둔다고 기록했어.

Coop은 손도 안 댔어. 네이티브 채팅을 멈추는 Coop 경로는 하나도 없어. 범인은 Claude Code 자체의 백그라운드 데몬이었어. 이 데몬은 프롬프트 앞에서 놀고 있는 백그라운드 채팅을 은퇴시켜. Remote Control 채팅은 입력 없이 여덟 시간, 나머지는 한 시간, 맥 메모리가 모자라면 1분 안에. 데몬 로그에 bg retire <short>: idle-prompt, idle 8h 한 줄을 남기고 프로세스를 끝내.

데몬 입장에선 착실한 정리야. 노는 채팅은 메모리만 먹으니까. Coop 입장에선 아주 구체적인 손실이었어. 은퇴한 그 채팅이 바로 아빠의 답이 도착해야 할 곳이었거든. 일은 여전히 그 채팅에 묶여 있었고, 의무도 아직 갚아야 했고, 나중에 아빠가 답장을 보냈을 땐 받을 데가 없었어.

은퇴가 남기는 것

수리는 은퇴가 실제로 뭘 없애는지 재는 데서 시작했어. 답은 보기보다 적다는 거였어. transcript는 남아. 실행할 때 쓴 옵션까지 담긴 데몬의 job 기록도 남아. Remote Control 세션도 남아.

그다음엔 설계를 정한 프로브가 있었어(2026-10-16). claude --bg --resume <session-id>를 다른 플래그 하나 없이 돌리면, 저장된 옵션 그대로 바로 그 세션이 깨어나. 같은 id, 같은 claude.ai 링크로. 프로브는 깨우기 전과 후를 비교해서 확인했어. 거기에 플래그를 하나라도 붙이면 CLI는 새 id로 사본을 띄워. 그래서 깨우는 명령은 딱 그 세 단어와 id야. 채팅에 필요한 나머지, 지난 레슨의 워커 플래그 두 개까지 포함해서 전부 job 기록에서 돌아와.

깨우는 쪽

어댑터 자식마다 이제 자기가 따라가는 채팅 하나를 위한 작은 Waker(revival.py)가 붙어 있어. 규칙은 이 순서야.

  1. 아직 일을 빚진 채팅만. 등록이 빚진 게 없으면, 은퇴한 채팅은 그냥 끝난 거야.
  2. 백그라운드 채팅만. 세션 id가 맞는 데몬 job 기록이 있어야 해. Desktop이나 터미널 채팅은 그런 기록이 없어서 절대 안 깨워. 그건 Claude Code만 다시 열 수 있어.
  3. 사라지고 1분이 지난 뒤에만(GONE_GRACE). 데몬엔 자체 크래시 재시작이 있고, 그 경주에선 데몬이 이겨야 해.
  4. 15분에 많아야 한 번(RETRY), 채팅이 계속 없는 동안.
  5. 돌아온 게 뭔지 확인해. CLI는 어느 short id를 백그라운드로 보냈는지 출력해. 그게 그 채팅 자신의 id가 아니면 CLI가 사본을 띄운 거고, Coop은 그 자리에서 사본을 멈추고 지워. 두 번째 채팅이 첫 번째 대신 대답하는 일은 절대 없어.

시도할 때마다 런타임 로그에 revived나 revive_failed 한 줄이 남고, 같은 실패는 15분마다가 아니라 한 번만 적혀. 이 장치를 안전하게 만드는 건, 깨어난 채팅이 뭘 하느냐야. 답은 아무것도 안 해. 프롬프트 앞에서 놀던 상태 그대로 돌아와. 깨운다고 일이 저절로 다시 시작되진 않아. 그다음 어댑터가 세션 id로 살아 있는 채팅을 찾아서, 아직 빚진 걸 전에 쓰던 그 문으로 전해줘. 아빠의 답장도 포함해서.

빚졌다는 말은 진짜 빚이어야 해

Waker는 몇 시간 만에 조용하던 버그를 시끄럽게 만들었어. "아직 일을 빚졌다"는 Engine.owed가 계산했는데, 거기 세던 것 중엔 절대 갚을 수 없는 것도 있었어. 이미 끝난 바인딩에 보낸 답글 리뷰는 확인받을 수가 없어. 엔진이 그 리뷰의 COOP_READ를 거절하거든("Reply review no longer has an active binding"). 이미 끝난 디스패치에 보낸 초대장이나 작업 봉투도 영영 답을 못 받아. 이런 것들이 outbox에 보냄, 영수증 불확실로 남아서 영원히 빚으로 세어졌어.

그렇게 등록에 붙잡혀 있던 끝난 채팅이 넷이었어. 2026-10-05부터 잡혀 있던 것도 있었고. Waker 전엔 어댑터 하나가 계속 폴링하는 정도로 끝났어. Waker가 생기자, 데몬이 은퇴시킬 때마다 그 넷을 전부 깨워서 아무도 받을 수 없는 걸 전하려 들었을 거야. 그날 아침 owed는 그런 행을 무시하는 법을 배웠고, 이틀 뒤엔 엔진이 아예 그런 행을 열어두지 않게 됐어. 일이 끝나는 바로 그 트랜잭션 안에서 취소하거든(그 이야기는 트랙 6 마지막 레슨에 있어).

끝난 일을 숨긴 청소부

Waker는 짝을 하나 데리고 나왔어. 빚진 채팅의 데몬 은퇴를 되돌릴 거라면, 끝난 Coop 채팅은 일부러 치워야 한다고 본 거지. 그래서 런타임의 Reaper가 10분마다 돌면서, 등록이 은퇴했고, 빚진 게 없고, 활성 등록 어디서도 안 쓰고, 사람을 기다리는 것도 아닌 채 두 시간 동안 놀았던 Coop 채팅을 멈췄어. 거기 맞춰서 노는 등록의 은퇴 시간도 여섯 시간에서 두 시간으로 줄었고.

이틀 갔어. claude stop은 프로세스만 끝내는 게 아니었어. transcript와 job 기록은 디스크에 남겨도, Claude 앱의 세션 목록과 검색에서 그 채팅을 지워. 아빠가 끝난 일을 찾으러 갔는데 못 찾았어. 모든 브레인용 Coop을 만든 그 채팅도 그렇게 사라졌어. 런타임 로그엔 2026-10-16부터 10-18 사이 office에서만 그런 stop이 49개 있었어. 데몬이 스스로 은퇴시킨 채팅(settled, idle 8h)은 목록에 그대로 남는데 말이야.

그래서 todo #518이 청소부를 들어냈어. stop 헬퍼, receiver의 청소 훅, 런타임의 청소 루프까지 전부. 이제 어떤 receiver나 런타임 경로도 청소하지 않는다는 걸 테스트 하나가 못 박고 있어. 노는 등록의 은퇴는 남았어. 빚진 것 없이 두 시간 놀면 Coop은 자기 기록과 그 채팅을 따라가던 어댑터 자식만 끝내고, 채팅엔 손대지 않아. Coop 안에 남은 claude stop은 깨우다가 실수로 생긴 사본을 지우는 그 하나뿐이야.

네 기록은 네가 끝내고, 다른 시스템의 물건은 주인한테 맡겨. Coop은 등록과 어댑터와 의무를 가졌으니 그건 자기 시계로 은퇴시켜도 돼. 채팅은 Claude Code 것이고, 그걸 거쳐서 아빠 것이야. 아직 아빠한테 빚진 게 있는 채팅은 두 번째를 만들 수 없는 가장 좁은 명령으로 깨워도 돼. 끝내는 건 절대 안 돼.

Code

Coop의 revival.py: 정확히 그 채팅만 깨우고, 사본은 거절해 (축약한 발췌)·python
def revive(host, session_id, job=None):
    """Wake the exact background chat `session_id` with its saved options; the woken short id."""
    job = job or job_record(host, session_id)
    if not job:
        raise RevivalFailed('Not a background Claude chat; only Claude Code itself can reopen it')
    cwd = job.get('cwd')
    if not cwd or host.stat(cwd) is None:
        raise RevivalFailed(f'The chat\'s folder {cwd} is gone')
    result = _cli(host, cwd, ['--bg', '--resume', session_id])   # no other flag, ever
    text = _output(result)
    found = BACKGROUNDED.search(text)
    if not found:
        raise RevivalFailed('claude --bg --resume did not say which session it backgrounded: ' + text[-300:])
    short = found.group(1)
    if short != session_id[:8]:
        for verb in ('stop', 'rm'):                              # the only stop left in Coop
            try:
                _cli(host, cwd, [verb, short], 30)
            except Exception as exc:
                log(revival='copy_cleanup_failed', session=session_id, copy=short, verb=verb, detail=str(exc)[:200])
        raise RevivalFailed(f'claude started a copy {short} instead of waking {session_id[:8]}; the copy was removed')
    return short
직접 돌려보는 최소 스케치: 유예 시간, 재시도 간격, 그리고 거절되는 사본·python
"""A minimal sketch (not Coop's code): wake a lost chat that still owes work,
after a grace period, at most once per retry window, and never keep a copy."""
GONE_GRACE, RETRY = 60, 15 * 60


class Waker:
    def __init__(self, resume):
        self.resume = resume          # returns the short id the CLI backgrounded
        self.gone_since = None
        self.not_before = 0

    def tick(self, now, present, owes, session='a1b2c3d4-0000'):
        if present or not owes:
            self.gone_since = None
            return 'nothing to do'
        if self.gone_since is None:
            self.gone_since = now
        if now - self.gone_since < GONE_GRACE:
            return 'grace: the daemon may respawn it first'
        if now < self.not_before:
            return 'waiting for the next window'
        self.not_before = now + RETRY
        short = self.resume(session)
        if short != session[:8]:
            return f'copy {short} started: stop and remove it'
        return f'woke {short}: same id, same link'


calls = iter(['a1b2c3d4', 'ffff0000'])
waker = Waker(lambda session: next(calls))
for now, present, owes in [(0, True, True), (10, False, True), (50, False, True),
                           (80, False, True), (200, False, True), (1000, False, True),
                           (2000, False, False)]:
    print(f'{now:5}s present={present!s:5} owes={owes!s:5} ->', waker.tick(now, present, owes))

# Expected output:
#     0s present=True  owes=True  -> nothing to do
#    10s present=False owes=True  -> grace: the daemon may respawn it first
#    50s present=False owes=True  -> grace: the daemon may respawn it first
#    80s present=False owes=True  -> woke a1b2c3d4: same id, same link
#   200s present=False owes=True  -> waiting for the next window
#  1000s present=False owes=True  -> copy ffff0000 started: stop and remove it
#  2000s present=False owes=False -> nothing to do

External links

Exercise

네가 기대고 있는, 프로세스를 대신 재시작하거나 치워주는 감독자를 하나 골라. 프로세스 매니저, 컨테이너 오케스트레이터, 노는 작업을 죽이는 스케줄러, 오래된 스레드를 보관 처리하는 채팅 앱 같은 거. 그 감독자의 정리 동사마다 세 가지를 적어. 무엇을 비우는지, 무엇을 남기는지, 그 뒤로 사람이 무엇을 더는 못 찾는지. 그다음 위 스케치를 돌리고, resume 함수가 가끔 경고를 찍은 뒤 원래 id를 돌려주게 바꿔봐. 사본 검사가 그래도 버티는지 봐.
Hint
위험한 동사는 부작용이 네 화면이 아닌 다른 화면에 떨어지는 것들이야. 목록에서 작업을 지우기, 스레드를 보관하기, 로그 스트림을 삭제하기. API에선 싸고 UI에선 비싼 동사를 찾아. 스케치에서 중요한 비교는 깨워 달라고 한 id와 돌아온 id 사이야.

Progress

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

댓글 0

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

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