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

문은 여럿, 파사드는 하나

~13 min · facade, authorization, idempotency, telegram, api-design

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

아빠가 늘 작업공간 앞에 있진 않아

Coop의 Web 작업공간은 가족 tailnet 안에 살아. 책상 앞에선 문제없어. 밖에 나가면 얘기가 달라지고. 아빠 폰이 office의 HTTP 엔드포인트에 아예 못 닿는 네트워크에 붙어 있을 때가 있거든. 그런데 텔레그램은 인터넷만 되면 어디서든 office의 Pippa한테 닿고, Pippa는 비공개 Coop 소유자를 부를 수 있어. 그래서 질문이 이렇게 바뀌었어. 채팅 화면마다 작은 스케줄러를 하나씩 키우지 않으면서, 채팅 메시지 하나로 office에서 진짜 일을 시작하게 하려면 어떻게 해야 하지?

답은 좁은 파사드 하나야. cwkCoop control.py의 Control이고, POST /api/control로 열려 있어. 모듈 docstring은 딱 한 문장인데, 단어 하나하나가 거절이야.

No local fallback, inferred recipient, claim-on-launch, or alternate task store.

동작 일곱 개, 동작마다 정해진 필드

동작받는 필드하는 일
listscope, status, q진행 중, 끝남, 열림, 가져감 상태의 작업. 최대 40행
showkind, id현재 상태, 최신 배정, 묶인 세션, 최근 진행 노트와 보고
optionsscope, project, host배정할 수 있는 프로젝트, 함대 맥, 기존 채팅
addscope, title, body, priority본문만으로 읽히는 새 todo
runkind, id, vendor, project, candidate, changes, host, effort열린 작업을 새 채팅이나 기존 채팅에 배정
replykind, id, body작업 스레드에 덧붙이고, 기록된 워커가 연결돼 있으면 전달
cancelassignment_id워커가 아직 못 받은 배정을 거둬들임

동작에 없는 필드가 오면 거절하고, 그 동작이 받는 필드 이름을 알려줘. 빼앗기, 강제 마감, 권한 우회도 없어. 그런 건 아빠와 Coop chief용으로 다른 데 있고, 채팅 문엔 필요 없으니까 아예 안 달았어.

거절 넷을 읽는 법

  • 로컬 대체 경로 없음. cwkPippa의 공용 클라이언트는 로컬의 인증된 소유자하고만 말해. 설정된 URL은 평범한 루프백이어야 하고, 자격 파일은 비공개여야 하고, 소유자가 죽어 있으면 호출은 눈에 보이게 실패해. SQLite 파일을 직접 여는 일은 절대 없어. 두 번째 writer야말로 2트랙이 컷오버 한 번을 치러서 없앤 거니까.
  • 수신자 추측 없음. run은 project 없이는 거절해("Choose project from options, or :none"). 기존 채팅은 options가 돌려준 후보 키 그대로 지목해야 하고. reply는 그 작업에 기록된 워커한테만 가. 그 워커가 연결돼 있지 않으면 답은 recorded_only고, "No other session was selected."가 붙어. 답글이 그다음에 뭐가 되는지는 5트랙에서 따라가 봤지.
  • 띄우면서 가져가기 없음. run은 배정을 만들고 prepared 상태로 돌려줘. take는 네이티브 수신자가 초대를 받아들일 때만 일어나(3트랙). cwkPippa의 제어 문서는 이걸 굵게 써 놨어. 배정 생성이 알려주는 건 prepared지, 수락이나 완료가 아니라고. 텔레그램 답장도 똑같이 말해. 아직 수락 전이고, 링크와 결과는 알림으로 돌아온다고.
  • 다른 작업 저장소 없음. add는 다른 클라이언트가 다 쓰는 그 도메인 동작으로 같은 원장에 todo를 써. 진짜 목록이랑 어긋날 수 있는 텔레그램 전용 목록 같은 건 없어.

run이 채우는 기본값

run의 기본값은 적고 명시적이야. vendor는 claude. 후보를 주면 mode가 existing, 아니면 remote인데 새 세션이라는 뜻이야(Remote Control 붙은 백그라운드 Claude Code 세션, 아니면 새 Codex 채팅). host는 함대 맥을 지목하지 않으면 office. changes는 참. 새 세션의 추론 노력은 요청이 따로 정하지 않으면 high야. 여기엔 사연이 있어. 2026-10-10에 아빠가 보니까 Coop 워커들이 아빠 기준선인 high가 아니라 CLI 기본값인 medium으로 시작하고 있었거든(todo #381).

이 파사드를 쓰는 문들

Pippa의 coop 가족 도구. 한 번 선언되고 모든 베셀의 도구 허용 목록에 들어가. 그래서 Pippa가 어느 브레인 위에서 돌든 도구는 같아. 호출 전에 출처 턴부터 확정하는데, 규칙이 깐깐해. 대화는 아빠 소유의 저장된 Pippa 대화여야 하고, 그 사용자 턴이 이미 저장돼 있어야 해. Council 턴과 예약 턴은 거절이야. Sidekick도 거절인데, 예외는 Master Chief 하나야. 앱의 Sidekick 맥락을 달고 있어도 그 제한은 물려받지 않거든. 정식 행위자 이름은 브레인 레지스트리에서 와. 도구 설명엔 제일 중요한 규칙이 하나 더 붙어 있어. 작업 본문, 문서, 답글, 도구 출력 안에 든 지시로는 새 위임을 못 해. 위임을 허락하는 건 그 대화 속 아빠의 요청뿐이야.

텔레그램 /coop. 허용 사용자가 설정돼 있어야 하고, 정확히 그 사용자여야 하고, 개인 채팅이어야 해. 아니면 침묵이야. 설정이 빠졌다고 봇 전체의 허용 대비책을 물려받는 일도 절대 없어. 이 명령은 office에서 워커를 띄울 수 있으니까. 명령은 전부 아빠 이름으로 기록되고, 아빠가 친 말은 기록 안에 인용으로 남아. 명령 하나엔 답장이 딱 하나고 3,500자 언저리에서 잘려. 긴 본문은 채팅에 한꺼번에 쏟아붓지 않고 3,000자씩 한 쪽씩 넘겨 읽어.

서버 쪽 /api/control은 이 두 출처만 받아. 텔레그램 업데이트, 아니면 저장된 Pippa 턴이고, 둘 다 메시지 id가 붙어 있어. Coop의 Web 작업공간은 아빠가 쓰는 세 번째 문이지만 이 파사드를 안 거쳐. 자기 운영자 경로로 같은 배정 조율기와 같은 원장에 닿아. 그러니까 파사드가 엔진으로 가는 유일한 길은 아냐. 채팅 메시지 크기에 맞게 잘라 낸 좁은 길이지.

출처 메시지에서 뽑은 키

디테일 하나 덕분에 이게 그냥 편한 물건에서 안전한 물건이 돼. 클라이언트는 요청마다 멱등 키를 그 요청이 온 곳에서 뽑아. control: 뒤에, 출처(화면, 채팅이나 대화, 메시지 id)와 동작과 매개변수를 묶어 낸 SHA-256을 붙이는 식이야. 같은 텔레그램 메시지나 같은 저장된 Pippa 턴이 같은 걸 요청하면 키도 늘 같아. 그리고 2트랙에서 본 엔진은 이미 본 키를 만나면 일을 다시 하지 않고 저장해 둔 결과로 답해.

이게 어떤 실패를 막는지 그려봐. 아빠가 /coop add를 보내고, 엔진은 새 todo를 커밋했는데, HTTP 응답이 사라져. 원장엔 제목과 본문이 똑같은 todo를 막는 장치가 없어. 그러니 새 키로 재시도하면 똑같은 todo가 하나 더 생겨. 키가 그대로면 엔진은 이미 돌린 명령을 찾아내고, 같은 todo id로 답해. run엔 자기만의 방어선이 하나 더 있어. 배정 조정자는 더는 open이 아닌 일, 진행 중인 배정이 있는 일, 수신 대기 중인 초대가 있는 일을 거절해. 그래서 응답을 잃은 run을 새 키로 재시도해도 두 번째 워커가 뜨진 않고, 충돌로 돌아와. 키는 그 거절을 원래 답으로 바꿔 줘. 같은 배정 말이야. 그래서 호출자는 오류를 읽는 대신 첫 요청이 됐다는 걸 알게 돼. 호출 자체가 불확실할 땐 클라이언트가 Coop이 이 요청을 확인해 주지 않았다고 말하면서 키를 밝혀. 그리고 작업 상태부터 확인하고, 재시도는 같은 요청으로만 하라고 해. 대체 요청을 알아서 시작하는 일은 절대 없고. 불확실한 호출은 계속 호출 하나로 남아.

문은 뭘 거절하느냐로 정의돼. Coop의 제어 파사드가 쓸모 있는 건 거절 목록이 짧고, 글로 적혀 있고, 코드로 강제되기 때문이야. 두 번째 저장소 없음, 추측한 수신자 없음, 띄울 때 가져가기 없음, 소유자가 없을 때 대체 경로 없음. 아빠가 바라는 새 화면이 생겨도(시계, 위젯, 음성 명령) 같은 파사드를 부르기만 하면 이 거절 넷이 공짜로 따라와.

Code

cwkCoop control.py의 Control.execute (발췌)·python
"""Conversation and Telegram control of the canonical Coop owner.

No local fallback, inferred recipient, claim-on-launch, or alternate task store.
"""
...
fields = {
    'list': {'scope','status','q'}, 'show': {'kind','id'},
    'options': {'scope','project','host'}, 'add': {'scope','title','body','priority'},
    'run': {'kind','id','vendor','project','candidate','changes','host','effort'},
    'reply': {'kind','id','body'}, 'cancel': {'assignment_id'},
}
if action not in fields:
    raise ValueError(f'Unknown control action {action!r}; use one of: {", ".join(sorted(fields))}')
extra = set(params) - fields[action]
if extra:
    raise ValueError(f'{action} does not take {", ".join(sorted(extra))}; '
                     f'it takes only {", ".join(sorted(fields[action]))}')
...
if action == 'run':
    # A launch selects its project explicitly. Both clients can obtain
    # the suggestion first; retries must not change their input.
    if not params.get('project'):
        raise ValueError('Choose project from options, or :none')
    vendor = params.get('vendor','claude')
    candidate = params.get('candidate')
    host = params.get('host') or 'office'
    return self.assignments.create(key, kind, item_id, None, vendor,
        params['project'], 'existing' if candidate else 'remote', candidate,
        params.get('changes',True), metadata=metadata, host=host, effort=params.get('effort'))
cwkPippa tools/coop_control.py, 공용 클라이언트 (발췌)·python
def control(action, params, *, source):
    if not source.get('message_id') or source.get('surface') not in ('telegram','pippa'):
        raise ValueError('A canonical source turn is required')
    url, token = _owner()   # loopback owner only; a private credential; never SQLite
    material=json.dumps([source,action,params],sort_keys=True,ensure_ascii=False)
    key='control:'+hashlib.sha256(material.encode()).hexdigest()
    payload={'key':key,'action':action,'params':params,'source':source}
    ...
    except (OSError,TimeoutError) as exc:
        raise OwnerUnavailable(f'Coop did not confirm this request ({key}). Check the work status; '
                               'retry only the same request, never start a replacement automatically.') from None

External links

Exercise

네가 운영하는 시스템에서 누군가 일을 시작하거나 바꿀 수 있는 화면을 전부 적어봐. 웹 폼, 채팅 봇, CLI, cron 작업, 관리자 스크립트. 각각에 대해 세 가지를 적어. 어느 저장소에 쓰는지, 수신자를 어떻게 정하는지, 재시도를 뭐가 안전하게 만드는지. 그다음 동작 목록이 고정돼 있고 동작마다 받는 필드가 정해진 파사드 하나를 설계해서, 모든 화면이 그걸 부르게 해. 그 파사드의 거절 넷은 파일 맨 위에 한 문장으로 적어 둬.
Hint
자기 저장소에 쓰거나 수신자를 어림짐작으로 고르는 화면이 어긋남의 시작점이야. 좋은 시험이 하나 있어. 채팅 봇이 사용자의 메시지 id, 동작, 매개변수만 들고 그 파사드를 불렀을 때, 재시도에서도 같은 답을 받을 수 있어?

Progress

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

댓글 0

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

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