agent-platform 세션 영속화는 ~.claude volume symlink + DB messages fallback 2-층 방어로 한다
요약
- Claude Agent SDK
query()의resume옵션은 호스트 파일시스템의~/.claude/sessions/<id>.jsonl에 의존한다. 이 디렉토리가 영속화되지 않으면 컨테이너 교체(=배포)마다 "No conversation found with session ID" 로 모든 멀티턴이 깨진다. - 1차 방어 (정상 케이스):
~/.claude를 영구 볼륨 안의 sub-path 로 symlink. fly.io 의 경우datavolume (/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 에 있으면 배포·재시작 한 번에 다 날아간다.
1차 방어 — volume symlink (Dockerfile)
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 같은 이유로 사라질 수 있다. 이때:
- agent.ts 에서
query()async generator 가 throw — 메시지에"No conversation found with session ID"포함 여부로 감지. - DB 의
messages테이블에서 해당sessionId의 마지막 N턴 (user/assistant/tool_use 평문) read. - user prompt 앞에 컨텍스트 prefix prepend:
[이전 대화 — SDK 컨텍스트 손실로 DB 에서 복원] user: ... assistant: ... ... [현재 질문] <원래 prompt> resume옵션 빼고 query 재시도. SDK 가 새 session_id 발급.- 매핑 테이블 (
sdk_session_map(db_session_id PK, sdk_session_id, updated_at)) 에db_session_id → new sdk_session_idupsert. - 다음 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 평문 영속화)