DeepSeek Harness · 영속화와 인프라

영속화 거버넌스: 버전, fork 경계, 해석 거부

로그 형식은 어떻게 진화하고, fork 경계는 어떻게 정하며, 읽지 못하는 데이터는 차라리 거부합니다.

강의 목표읽고 나면 세 가지를 말할 수 있어요. 세션 로그 형식 버전이 왜 단조 정수 하나인지; 신구 버전이 서로의 로그를 읽을 때 운명이 왜 다른지; 그리고 DSH 눈에는 알 수 없는 이벤트를 조용히 건너뛰는 일이 왜 보안 사고여서, 차라리 세션 전체를 열지 않는지.
인터랙티브 데모 · 로그 고고학 현장

먼저 해보고 말할게요. 위는 디스크의 세션 로그—header 한 줄과 이벤트 나열. 아래 로더 둘이 동시에 읽어요: 왼쪽은 DSH「해석 거부」식—못 읽으면 오류; 오른쪽은 흔한 best-effort 건너뛰기—못 읽으면 건너뛰고 계속. 시나리오 셋, 각기 문제 로그. 재생을 눌러 같은 바이트가 두 로더에서 어떻게 되는지 보세요.

디스크의 로그 (~/.dsh/sessions/session-42/log.jsonl.zst)
DSH 로더(해석 거부식)
(아직 복원된 메시지 없음)
best-effort 로더(건너뛰기식)
(아직 복원된 메시지 없음)
「재생」을 눌러 두 로더가 같은 로그를 동시에 읽는 모습을 보세요.
데모는 수업용 시뮬레이션이지만, 왼쪽 거부 문장 두 줄은 coordinator.ts 79행·1064행 템플릿을 글자 그대로 복제했어요. 실제 DSH에는 오른쪽 best-effort 로더가 없습니다—대조용 반면교사예요.
로직 분해 · 정수 하나가 버전을 맡는다

한 줄 배경: DSH 세션 로그가 유일한 진실이고, 복구·fork·리플레이가 전부 여기서 파생돼요(이전 레슨의 불변조건). 진실은 어떤 프로그램 버전보다 오래 살아야 하니 형식 진화는 작은 일이 아니에요. 오늘 쓴 로그를 내년 harness가 읽어야 하고, 반대로 새 버전이 쓴 로그가 옛 버전에 오면 옛 버전은 못 읽는다는 걸 알아야 합니다.

DSH 버전 방안은 숫자 하나로 단순해요: SESSION_FORMAT_VERSION, 현재 0, packages/core/session/src/types.ts 56행. 1.2.3 같은 대·소 버전이 없어요. 설계 노트 이유: 어떤 단계가 자동 변환 가능한지는 그 단계 업그레이더를 쓸 수 있느냐로 정해지고, 두 단 번호는 설계 때 모르는 일을 미리 약속하는 셈입니다.

올릴지 말지 기준은 분명해요. 옛 런타임이 새 로그를 의미상 완전히 올바르게 다룰 수 없을 때만 올립니다. 「파싱만 되면」은 안 쳐요—다 읽었는데 잘못된 세션을 재구성하면 그건 오독입니다. 확 안 서면 올리세요. 거의 항등인 업그레이더는 비용이 거의 없고, 한 번 놓치면 옛 버전이 조용히 데이터를 망가뜨립니다.

세 운명 · 방향별 읽기 규칙

저장된 로그를 열 때 먼저 버전을 비교하고, 세 결과가 전혀 다른 처리로 이어져요:

저장된 로그 열기 header.version 읽기 같음 정상 읽기 그다음 이벤트마다 알 수 없는 타입 가드 로그가 더 오래됨 업그레이더 체인 n → n+1 단계 변환 조회는 메모리에서만 변환, 파일은 그대로 세션을 이어갈 때만 저장: 원자 교체 + 백업 유지 로그가 더 새로움 거부하고, 방향을 밝힌다 「harness를 업그레이드하세요」+ 원본 파일 경로 알 수 없는 이벤트 타입 가드 KNOWN_SESSION_EVENT_TYPES에 있나? 통과 없는데 ignorable: true? 건너뛰기 없고 표시도 없음? 세션 전체 복원 거부 두 게이트는 모두 읽기 쪽: 쓰기 쪽은 어휘 검사를 안 해요. 쓸 때 거부하면 활성 세션 영속화가 중간에 멈춥니다
수업용 구조도: session-log-version-mechanism Agent Note와 coordinator.ts 소스를 바탕으로 정리했습니다.

가장 곱씹을 칸은 「거부하고, 방향을 밝힌다」예요. 예전 assertVersion은 버전 불일치마다 같은 모호한 오류를 던졌고, 바꾼 뒤에는 방향별로 갈라요. 로그가 더 새로우면 「더 새 harness가 썼으니 업그레이드하세요」와 원본 경로를 말하고; 더 오래됐는데 업그레이드 체인이 끊겼으면 「이 빌드에는 업그레이드 경로가 없습니다」라고 합니다. 사용자는 늘 「업그레이드할 때」를 보지, 「파일 손상」을 보지 않아요. 데이터는 안 망가졌는데 손상이라 하면 억울합니다.

옛 로그를 읽는 방향에도 디테일이 있어요. 새 버전이 옛 로그를 열면 업그레이더 체인은 메모리에서만 단계 변환하고, 한눈 보는 건 저장하지 않아요. 사용자가 진짜 세션을 이어갈 때만 결과가 원자 교체로 디스크에 쓰이고 원본은 백업으로 남습니다. 설계 노트는 「조회 시 자동 마이그레이션 저장」을 거절했어요. 열자마자 고치면 읽기가 파괴적 쓰기가 되고, 변환기에 버그가 있으면 브라우징만으로 로그가 망가집니다.

왜 「알 수 없는 이벤트 건너뛰기」가 보안 사고인가

버전 번호는 구조 변경을 다루고 어휘 성장은 못 다뤄요. 이벤트 종류는 걸린 플러그인에 달려 정수 하나로 설명할 수 없습니다. DSH 해법은 이벤트마다 표시. 읽기가 모르는 타입을 만나면 기본으로 세션 전체 복원을 거부하고, 봉투에 작성자가 선언한 ignorable: true가 있을 때만 예외예요. 알려진 어휘 KNOWN_SESSION_EVENT_TYPES는 손으로 안 쓰고, 저장소 전체 이벤트 선언을 스크립트가 합쳐 44종으로 만들며, 946줄 영속 이벤트 목록 docs/persistence-catalog.zh.md와 함께 전용 검증 스크립트가 낡지 않게 지킵니다.

왜 기본은 필수이고, 표시를 빼먹으면 차라리 과하게 거부하나? 설계 노트가 셈을 분명히 해요. ignorable를 빼먹으면 복구 가능했던 세션이 거부되어 사용자가 불편—경험 문제; 반대로 기본 무시면 같은 실수가 내용이 빈 세션을 조용히 복원하고 모델이 잘못된 이력 위에서 일합니다—보안 사고예요. 데모 시나리오 A가 후자의 현장: 사용자 메시지를 담은 알 수 없는 이벤트를 건너뛰면, 복원된 대화에서 어시스턴트가 없는 질문에 답합니다. 실패가 비대칭이라 방벽은 시끄러운 쪽으로 기울어요.

버전은 단조 정수 하나

대·소 버전을 나누지 않아요. 자동 업그레이드 가능 여부는 그 단계 업그레이더 존재로 표현하고, 번호 체계는 미리 약속하지 않습니다. 현재 SESSION_FORMAT_VERSION = 0.

알 수 없는 이벤트는 기본 필수

읽기는 알 수 없는 타입이 있는 로그의 해석을 거부하고, 이벤트에 ignorable: true가 있을 때만 예외예요. 방향성 거부가 best-effort parse보다 낫고, 조용한 건너뛰기는 오독입니다.

fork 경계를 두 번 쓴다

header의 seedLength는 영속적인 혈통 경계이고; 로그의 session/end-seed 이벤트는 저장 바이트만 가진 독자를 위한 거예요. seed.length는 어느 쪽도 대신하지 못합니다.

fork의 이중 경계 · seedLength와 session/end-seed

세션을 fork한다는 건 원 세션을 어떤 안정 지점까지 이벤트를 깊은 복사해 자식 세션의 씨앗으로 쓰는 일이에요. 골치는 경계: 자식 로그 앞반은 상속 씨앗, 뒷반은 스스로 쓴 것인데 바이트로는 똑같아 보입니다. 어디에 금이 그어지나?

직관적 답은 생성 때 씨앗이 몇 줄인지 세는 것, 곧 seed.length. 이 답은 교묘히 틀려요. 복원된 세션은 전체 저장 로그를 생성 씨앗으로 쓰니 seed.length 경계가 다시 열릴 때마다 뒤로 밀리고; header의 seedLength만이 처음 fork 때의 값을 지킵니다. 그래서 DSH는 경계를 두 번 씁니다. 첫째 header—fork()가 자식 생성 때 parentSessionseedLength를 메타에 넣고; 둘째 로그—씨앗 있는 세션이 session/end-seed를 씨앗 뒤 첫 실시간 쓰기로 붙여, 저장 바이트만 가진 소비자를 위해 씁니다.

이 경계 이벤트가 푸는 문제는 구체적이에요. 씨앗 이력에 짝 없는 compaction/start가 있을 수 있는데, 「지난 수명주기가 압축 중간에 죽었다」인지 「지금 압축 중」인지 바이트만으로는 모릅니다. session/end-seed가 있으면 그 앞의 짝 없는 시작 표시는 모두 끝난 수명주기 몫이에요. 타입 JSDoc에는 독한 한 줄이 있습니다: Session 생성자만이 합법 작성자이고, 플러그인이 제멋대로 한 줄을 붙이면 그 앞의 모든 실시간 작업을 조용히 씨앗 이력으로 분류하는 셈입니다.

큰 로그 복원 비용 한 마디. 이벤트 130만·압축 62MiB 세션을 복원할 때 DSH는 전체 평문을 물질화하지 않고, 이번 최적화로 복원 허가를 약 600ms에서 263ms로 줄였어요(Agent Note 2026-08-05). 체크섬과 동결은 하나도 아끼지 않았습니다. 영속 저장은 런타임 경계라 방벽 자체는 움직이지 않아요.

핵심 증거 · 거부의 원문

「방향별 구분」은 소스에서 다섯 줄 작은 함수 sessionFormatVersionRefusal예요. 버전이 더 크면 「더 새 harness가 썼으니 harness를 업그레이드해 여세요」; 더 작은데 경로가 없으면 「이 빌드에는 업그레이드 경로가 없습니다」. 코디네이터 로드 검사와 각 저장 백엔드가 공유하고, 백엔드는 어떤 구조든 디코딩하기 전에 이걸로 외부 버전을 거부해 사용자가 늘 「업그레이드하세요」만 보게 합니다—「손상」이 아니에요. 데모 왼쪽 빨간 오류가 그 원문입니다.

출처:packages/session/session-persistence/src/coordinator.ts 77–81행, 확인일 2026-08-13.

「알 수 없는 이벤트 기본 거부」가드는 더 짧아 루프 하나예요. 타입이 목록에 있거나 작성자가 ignorable를 달면 통과; 아니면 던지며 이벤트 타입·seq·「대개 더 새 harness가 씀」방향 힌트를 실어요:

packages/session/session-persistence/src/coordinator.ts1061–1066행
  private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
    for (const event of events) {
      if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
      throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
    }
  }
소스 스냅샷 안내:로컬 저장소 deepseek-harness-master 기준, 확인 파일 packages/session/session-persistence/src/coordinator.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.
가로 비교 · 다른 집은 읽지 못하는 데이터를 어떻게 다루나

Grok Build

세션 요약 읽기는 표준 serde 역직렬화 경로예요. persistence.rs 700–725행 resume 선읽기 루프에서 읽히지 않거나 파싱 안 되는 summary.json은 바로 continue로 건너뛰고, 오류도 흔적도 없습니다.

세션 관련 serde 구조체에 deny_unknown_fields가 한 곳도 없어 알 수 없는 필드는 기본으로 조용히 버려요. 새 버전이 넣은 필드를 옛 버전이 읽고 다시 쓰면 사라집니다. 빠른 제품 제품의 흔한 취사선택이고, 형식 진화의 올바름을 「신구 버전을 섞지 말자」가정에 맡길 뿐입니다.

Claude Code

세션은 ~/.claude 아래 .jsonl로 두고 resume·조회를 지원해요. 원고 자료(claude-code-sourcemap study 장)는 기동·컨텍스트 관리·관측 가능성을 다루지만, 세션 로그 형식 버전 협상이나 알 수 없는 기록 거부 메커니즘의 복원 코드는 나오지 않습니다.

공개 증거 기준으로, 모르는 데이터를 읽었을 때의 동작은 알 수 없어요. 클로즈드 제품은 「클라이언트가 늘 최신」으로 버틸 수 있지만; DSH는 오픈 인프라라 버전이 오래 공존해 그 가정이 성립하지 않으니, 거부 규칙을 읽기에 넣었습니다.

수업 실습
01

플러그인 이벤트에 기본값을 고르세요

DSH 플러그인을 만들어 세션 로그에 맞춤 이벤트 myplugin/audit를 붙여 도구 호출마다 감사를 남긴다고 해 봐요. 두 경우를 추리하세요. ignorable를 안 달고 사용자가 플러그인 없는 같은 버전 harness에 로그를 복사해 열면? (힌트: KNOWN_SESSION_EVENT_TYPES는 저장소 안 선언으로 생성되고, 저장소 밖 플러그인 이벤트는 구조상 목록 밖.) ignorable: true를 달면? 감사 정보는 재구성에서 어디로 가나? 각 선택은 어떤 이벤트에 맞는지 「잃으면 나머지 로그 해석이 달라지나」자로 재 보세요.

Takeaway:버전은 단조 정수 하나이고, 읽기 규칙은 방향별로: 같으면 정상 읽기, 더 오래되면 업그레이더 체인으로 메모리 변환, 더 새로우면 분명히 거부하며 「업그레이드하세요」를 가리킵니다. 알 수 없는 이벤트는 기본 해석 거부—과한 거부는 경험 문제, 조용한 건너뛰기는 보안 사고. fork 경계는 두 번 씁니다. header의 seedLength와 로그의 session/end-seed, seed.length는 어느 쪽도 대신 못 해요.