# co-gahusb — 세션 간 협업 팀 버스 설계

작성일: 2026-06-12
대상 repo: `web-backend` (서버) + `web-ui`/`web-ai` (클라이언트 배선)
목적: 독립 실행되는 4개 Claude Code 세션(FE/BE/AI/Producer)이 역할을 갖고 비동기로 소통·협업하되, 공유 DB/리소스는 동시 쓰기를 방지한다.

## 배경

web-ui / web-backend / web-ai 세션은 각각 독립 프로세스라 서로의 컨텍스트를 못 본다. 협업하려면 세 곳(서로 다른 머신 포함)에서 닿는 공유 메시지 버스가 필요하다. 사용자가 방식 B(독립 MCP 서버)를 선택했고, 민감한 공유 영역의 동시 쓰기 분리를 핵심 요구로 명시했다.

## 결정 사항 (브레인스토밍 확정)

- 호스팅: 신규 독립 컨테이너 **`co-gahusb`**, NAS, 포트 **18920**(18900 agent-office 옆, 미사용 확인).
- 전송/인증: **HTTP streamable MCP** + 정적 **Bearer 키**([[reference_webai_auth_pattern]] 재사용). nginx `/api/co/` → `co-gahusb:18920`, `Authorization` forward.
- 백엔드: **Redis**(기존 공유 컨테이너 `redis://redis:6379`). 전 연산 원자적 → SQLite multi-writer 함정([[reference_sqlite_concurrency]]) 회피.
- 동시쓰기 분리: **소유권 파티션 + 어드바이저리 락**.
- 역할: web-ui=FE, web-backend=BE, web-ai=AI, 이 세션=Producer.
- 수신: 각 세션 **/loop 폴링**(`read_inbox` + `list_tasks`).

## 아키텍처

```
[FE 세션 web-ui]   [BE 세션 web-backend]   [AI 세션 web-ai(다른 머신)]   [Producer 세션]
       \                  |                        /                         /
        \                 |                       /                         /
         ──────── .mcp.json HTTP + Bearer ───────────────────────────────
                                   │
                    nginx /api/co/ (Authorization forward)
                                   │
                       co-gahusb:18920  (FastMCP streamable-http)
                                   │
                          Redis (원자적 연산)
```

서버 구현: **Python `mcp` SDK(FastMCP) + streamable-http transport**(모든 lab이 FastAPI/Python 스택과 일관). 단일 책임 모듈로 분리:
- `app/server.py` — FastMCP 인스턴스 + 툴 등록 + ASGI 앱(streamable-http) + Bearer 인증 미들웨어
- `app/store.py` — Redis 데이터 액세스 레이어(메시지/작업/락), 전 함수 원자적
- `app/locks.py` — 락 Lua 스크립트(소유자 확인 후 release/heartbeat)
- `app/models.py` — 입출력 dataclass/스키마
- `app/config.py` — env(REDIS_URL, CO_BUS_KEY, 포트)

## MCP 툴 표면 (MVP — YAGNI)

| 분류 | 툴 | 시그니처 → 반환 |
|------|-----|------|
| 메시지 | `post_message` | `(from_role, to_role, body, thread_id?)` → `{message_id}` |
| 메시지 | `read_inbox` | `(role, after_id?, mark_read?=false)` → `{messages:[{id, from_role, body, thread_id, ts}], cursor}` |
| 작업 | `create_task` | `(title, assignee_role, detail?, created_by)` → `{task_id}` |
| 작업 | `claim_task` | `(task_id, role)` → `{ok, task}` (이미 claim 시 `{ok:false, held_by}`) |
| 작업 | `update_task` | `(task_id, status, role, note?)` → `{ok, task}` (status ∈ open/in_progress/blocked/done) |
| 작업 | `list_tasks` | `(status?, assignee_role?)` → `{tasks:[...]}` |
| 락 | `acquire_lock` | `(resource, role, ttl_sec=300)` → `{acquired, held_by?, ttl_remaining?}` |
| 락 | `release_lock` | `(resource, role)` → `{released}` (소유자 아니면 `{released:false}`) |
| 락 | `heartbeat_lock` | `(resource, role, ttl_sec=300)` → `{renewed}` (소유자만) |
| 락 | `list_locks` | `()` → `{locks:[{resource, held_by, ttl_remaining}]}` |
| 가시성 | `team_log` | `(after_id?)` → `{events:[...], cursor}` (최근 활동 피드) |

## Redis 데이터 모델 (전부 원자적)

- **메시지**: `co:inbox:{role}` = Redis **Stream**. `post_message`=XADD, `read_inbox`=XREAD(`after_id` 커서, 비파괴). `mark_read`는 `co:read:{role}` 키에 마지막 id 저장.
- **작업**: `co:task:{id}` Hash(title/assignee/status/detail/created_by/ts), `co:tasks` Set(id 목록), `INCR co:taskseq`로 id. `claim_task`/`update_task`는 **Lua 스크립트**로 read-modify-write 원자화(중복 claim/경합 방지).
- **락**: 획득 = `SET co:lock:{resource} {role} NX EX {ttl}`(원자적). `release_lock`/`heartbeat_lock` = **Lua**로 `GET` 소유자 일치 확인 후 `DEL`/`EXPIRE`(check-and-act 원자화 → 남의 락 조작 불가).
- **활동로그**: `co:log` = 캡트 Stream(`XADD ... MAXLEN ~ 500`). 메시지·작업·락 이벤트 기록 → Producer 오버사이트.

## 동시 쓰기 분리 (핵심 요구)

**1차 — 정적 소유권 파티션** (락 불필요한 자연 분리):
- `web-ui` → FE만, `web-backend` → BE만, `web-ai` → AI만 쓰기. 각 세션은 자기 repo만 편집 → git 충돌 원천 차단.

**2차 — 교차 리소스 어드바이저리 락** (여러 역할이 건드릴 수 있는 민감 영역만):
- 예약 resource 명: `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`(web-ui↔web-ai 미러), `nginx-conf`, `compose`.
- 규약: 위 리소스 변경 전 `acquire_lock` 필수. 점유 중이면 `{acquired:false, held_by, ttl_remaining}` → 대기. **TTL 자동 해제로 세션 사망 시 데드락 방지**, 긴 작업은 `heartbeat_lock` 갱신.
- 어드바이저리(협조적): 버스는 FS를 강제 잠그지 않음 → 각 세션 CLAUDE.md에 "공유 리소스 = 락 먼저" 규약 명문화로 강제.

## 클라이언트 배선

- 각 repo `.mcp.json`:
  ```json
  { "mcpServers": { "co-gahusb": {
      "type": "http",
      "url": "https://gahusb.synology.me/api/co/mcp",
      "headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
  ```
  (키는 커밋 금지 — 각 머신 env/로컬에서 주입. `.mcp.json`엔 placeholder, 실제 키는 `.env`/환경변수.)
- 각 repo CLAUDE.md에 역할 블록 추가: "너는 역할 X / 모든 co-gahusb 툴에 role=X / 공유 리소스 변경 전 acquire_lock / `/loop`로 inbox·tasks 폴링".
- web-ai는 다른 머신 → 해당 머신에서 `.mcp.json` 적용(스펙에 절차 명시).

## 인프라 등재 (신규 컨테이너 추가 의무 위치 — [[reference_nas_url_routing]], [[reference_deploy_nas_services_whitelist]])

1. `docker-compose.yml` — `co-gahusb` 서비스(build, `REDIS_URL`, `depends_on: redis`, `CO_BUS_KEY` env, `${RUNTIME_PATH}` 볼륨 불요(상태는 Redis)).
2. nginx `default.conf` — **public `location /api/co/`** 추가(7번째 등재 규칙; `/api/internal/` 불필요).
3. deploy 스크립트 SERVICES 화이트리스트에 `co-gahusb` 등재.
4. `${RUNTIME_PATH}` 절대경로 — 본 서비스는 영속 볼륨 없음(Redis 백엔드)이라 코드 디렉토리만.
5. frontend `depends_on` — 불필요(백엔드 전용 서비스).
6. `.env` — `CO_BUS_KEY` 추가(커밋 금지).

## 에러 / 엣지 처리

- 인증 실패 → 401, 1회만 ERROR 로그 후 조용([[reference_webai_auth_pattern]]).
- 락 획득 실패 → 예외 아닌 `{acquired:false, held_by, ttl_remaining}` 정상 반환.
- 만료 락 → Redis TTL 자동 소멸(별도 GC 불필요).
- 알 수 없는 role/resource → 명시적 에러 메시지.
- Redis 연결 실패 → 503 + 명확한 메시지.

## 테스트 (TDD, pytest + fakeredis)

- **락**: 두 역할 같은 resource 획득 → 2번째 거부 / TTL 만료 후 획득 / 소유자 아닌 release·heartbeat 거부 / heartbeat 갱신 후 ttl 증가.
- **메시지**: XADD 순서대로 `after_id` 커서 읽기 / mark_read 후 재읽기 시 제외 / 다른 role 우편함 격리.
- **작업**: create→claim(중복 claim 거부)→update status 전이 / list 필터.
- **인증**: 키 일치 통과 / 불일치 401.
- **team_log**: 이벤트 기록 + MAXLEN 캡.

## 구현 순서 (phase)

1. 스캐폴드: 디렉토리/Dockerfile/requirements/config (기존 lab 구조 미러)
2. `store.py` + `locks.py` (TDD, fakeredis) — 락 → 메시지 → 작업 → team_log
3. `server.py` — FastMCP 툴 등록 + Bearer 인증 + ASGI
4. 인프라 등재 6위치 (compose/nginx/deploy/env)
5. 클라이언트 배선: web-ui·web-backend `.mcp.json` + CLAUDE.md 역할 블록 (web-ai는 절차 문서화)
6. 배포(Gitea push → webhook) + 스모크 테스트(헬스/인증/락 경합)

## 비범위 (YAGNI)

- 실시간 push(텔레그램) — 후속. 우선 /loop 폴링.
- SQLite 감사로그 — Redis 캡트 스트림으로 충분.
- 웹 대시보드 — agent-office 오버사이트와 추후 통합 여지.
- 락의 FS 레벨 강제 — 어드바이저리로 충분(세션은 협조적).
