Zettelkasten

agent-platform 세션 영속화는 ~.claude volume symlink + DB messages fallback 2-층 방어로 한다

·수정 2026.05.11·수정 1

요약

  • Claude Agent SDK query()resume 옵션은 호스트 파일시스템의 ~/.claude/sessions/<id>.jsonl 에 의존한다. 이 디렉토리가 영속화되지 않으면 컨테이너 교체(=배포)마다 "No conversation found with session ID" 로 모든 멀티턴이 깨진다.
  • 1차 방어 (정상 케이스): ~/.claude 를 영구 볼륨 안의 sub-path 로 symlink. fly.io 의 경우 data volume (/app/data) 안에 claude-home/ 두고 ln -sfn /app/data/claude-home /home/agent/.claude.
  • 2차 방어 (예외 케이스): SDK session 파일이 손상/만료된 경우, DB 에 저장해둔 user/assistant/tool_use 메시지 평문을 prompt prefix 로 임베드해 새 SDK session 으로 재시작. SDK session ID 와 DB session ID 는 매핑 테이블로 분리해야 클라이언트가 같은 conversation 으로 인식한다.

본문

왜 이게 함정인가

Claude Agent SDK 를 HTTP 서버로 래핑하면 멀티턴은 보통 클라이언트가 받은 sessionId 를 다음 요청에 재전송 → 서버가 query({ resume: sessionId }) 로 SDK 에 위임 → SDK 가 내부적으로 별도 Claude Code CLI 프로세스를 띄우면서 ~/.claude/sessions/<id>.jsonl 을 읽는다.

이 jsonl 은 SDK 가 호스트 파일시스템 에 쓰는 단일 진실 소스(single source of truth). 메모리/DB가 아니라 디스크 파일이라, 컨테이너 ephemeral storage 에 있으면 배포·재시작 한 번에 다 날아간다.

USER agent
# ...
CMD ["sh", "-c", "mkdir -p /app/data/claude-home && rm -rf /home/agent/.claude && ln -sfn /app/data/claude-home /home/agent/.claude && exec node packages/server/dist/index.js"]
  • /app/data 는 fly volume mount point. claude-home/ sub-path 만 추가하면 새 volume 추가 없이 끝남.
  • rm -rf /home/agent/.claude 는 매 시작 시 디렉토리/심볼릭링크 상태를 정규화 (Claude CLI 가 컨테이너 안에서 빈 .claude 를 만들어둘 수 있음).
  • ln -sfn-n 은 link_name 이 이미 symlink 면 그 symlink 자체를 갱신 (디렉토리 안에 새 symlink 만들지 않도록).

별도 volume 을 안 쓰는 이유 (fly.io 한정)

새 volume 추가하면 --require-unique-zone 기본값이 true 라 기존 volume 과 다른 zone 에 배정될 가능성이 높다. fly 의 multi-volume mount 는 같은 machine 에 attach 되는 모든 volume 이 같은 zone 에 있어야 한다. zone 명시 옵션이 없어 (--require-unique-zone=false 도 zone 매칭을 보장 X), 기존 volume sub-path 활용이 가장 안정적.

2차 방어 설계 — DB messages fallback (옵션)

1차 방어가 정상이어도 SDK session 파일은 손상/SDK 버전 변경 / 7일 retention 같은 이유로 사라질 수 있다. 이때:

  1. agent.ts 에서 query() async generator 가 throw — 메시지에 "No conversation found with session ID" 포함 여부로 감지.
  2. DB 의 messages 테이블에서 해당 sessionId 의 마지막 N턴 (user/assistant/tool_use 평문) read.
  3. user prompt 앞에 컨텍스트 prefix prepend:
    [이전 대화 — SDK 컨텍스트 손실로 DB 에서 복원]
    user: ...
    assistant: ...
    ...
    
    [현재 질문]
    <원래 prompt>
    
  4. resume 옵션 빼고 query 재시도. SDK 가 새 session_id 발급.
  5. 매핑 테이블 (sdk_session_map(db_session_id PK, sdk_session_id, updated_at)) 에 db_session_id → new sdk_session_id upsert.
  6. 다음 turn 부터 클라이언트가 같은 db_session_id 보내면 매핑 lookup 후 새 sdk_session_id 로 resume.

왜 매핑 테이블이 필요한가

SDK session ID 와 DB session ID 를 같이 쓰면 fallback 후 새 SDK session ID 가 클라이언트로 노출 → 클라이언트 storage 갱신 필요 → UI 의 conversation list 가 깨질 수 있음. 매핑으로 분리해야 클라이언트는 같은 ID 유지, 서버 내부에서만 SDK ID 교체.

트레이드오프 — 어디까지 방어할지

  • 1차만: 운영 99% 케이스 커버. 작업량 작음 (Dockerfile 한 줄). 단 session 파일 손상 / SDK retention 만료 같은 예외엔 무방비.
  • 1차 + 2차: 모든 케이스 커버. 매 fallback 마다 prefix 토큰 비용 발생 (마지막 N턴 ~2-5K 토큰 1회). 매핑 테이블 schema 변경 + agent.ts 에 retry 로직 + emit 시 sessionId 변환 등 작업량 ↑.

처음엔 1차로 시작 → 운영 중 예외 빈도 보고 2차 추가 검토가 합리적.

참고

  • fly multi-volume zone constraint: fly volumes create --require-unique-zone=false 도 zone 매칭 보장 X
  • Claude Agent SDK query() API: resume?: string 옵션이 ~/.claude 의 jsonl 에 의존하는 구조
  • agent-platform 의 packages/server/src/services/agent.ts (SDK 호출), services/messagesDb.ts (messages 평문 영속화)