Esc 이후에 일어나는 일: 취소, 크래시 복구, 재진입
Turn마다 취소 시그널 하나, 중단돼도 장부를 맞추고, kill -9 뒤 재시작해도 이어서 달리며, 디스크에 남은 성과는 하나도 잃지 않습니다.
turn/end를 로그에 쓰는지; 그리고 프로세스가 kill -9된 뒤, 재시작된 DSH가 무엇으로 반쯤 끊긴 세션을 살리고 무엇을 지키는지.
지금 일하는 Turn 하나예요. 모델이 스트리밍 중이고 긴 도구도 호출했습니다. 왼쪽은 세션 로그, 오른쪽은 런타임 상태. 시나리오 버튼 세 개는 사고 세 종류에 대응해요: Esc, kill -9 후 재시작, 그리고 대조용 가상 절단식 복구. 「재생」을 누르면 자막이 매 단계 로그에 뭐가 늘고 줄었는지 알려 줍니다.
packages/core/agent-loop/src/agent.ts(취소와 turn/end 로깅), packages/core/session/src/repair.ts(크래시 복구 closer 합성)에 대응해요. 시나리오 C의 절단식 복구는 수업용 가설이며 DSH는 구현하지 않습니다—데이터 손실을 대조하려고요.어떤 문제를 푸는가
취소를 전역 스위치로 둔다고 상상해 보세요. agent에 불린 플래그를 달아 두고 누구나 켜고, 코드는 틈날 때 한눈 팔아요. 사고는 언젠가 납니다. 이전 Turn이 등록한 타임아웃 콜백이 한밤중에 깨어 지금 도는 새 Turn을 취소하거나, Promise.race로 취소를 구현해 race에서 진 도구 호출은 뒷수습 없이 백그라운드에서 파일을 고치고 상태를 쓰는 좀비 작업이 됩니다.
취소의 난점은 깨끗이 멈추고, 멈춰야 할 것만 멈추는 일이에요. 전역 스위치는 둘 다 못 줍니다.
아이디어는 무엇인가
DSH의 취소는 명시적으로 전달되는 선이에요. 한쪽은 Turn에, 다른 쪽은 일하는 경계마다 묶입니다. 드라이버가 깨어 일할 때마다 AbortController를 새로 만들고, Turn이 끝나고 큐에 일이 남으면 새것으로 바꿉니다. 한 시점에 유효한 컨트롤러는 최대 하나, 취소 권한의 수명은 Turn과 같습니다.
Esc는 그 선을 살짝 당길 뿐이에요. UI는 키를 agent.cancel({ kind: 'user' })로 옮기고, 부모가 자식 agent를 끊으면 { kind: 'parent' }—취소에는 신원이 붙습니다. cancel 입구는 외울 만큼 작아요. 동작은 둘: 기본은 inbox를 비워 대기 중 미실행 메시지를 모두 폐기하고(대기 일을 지키려면 keepInbox), 현재 활동만 끊은 뒤 현재 컨트롤러에 abort(cause). 대기 중 cancel은 no-op이라 미래 일에 취소 상태를 미리 심지 않습니다.
같은 signal을 pre-step, 프롬프트 조립, 모델 요청, 스트림 읽기, 도구 실행, 승인에 명시적으로 넘기고, bash 도구도 이를 따라 프로세스 그룹 전체를 죽입니다. 전달은 협조적이에요. 루프는 매 await 경계 전후에 중단을 확인하고, Promise.race로 아직 도는 Promise를 중간에 버리지 않아 좀비 작업이 몰래 상태를 못 바꿉니다.
마지막은 권한 인수인계. 루프는 turn/end를 발행하기 전에 이번 Turn의 취소 보유자를 치워요. 그 뒤 영속화 flush가 아직 정산 중이어도, 끝난 Turn 일은 아무도 취소 못 합니다. 다음 Turn은 새 signal을 받고, 옛 콜백이 월권하려 해도 손잡이조차 못 찾습니다.
출처:Turn마다 AbortController 생성은 packages/core/agent-loop/src/agent.ts 187행, 끝난 뒤 교체는 325행, cancel 입구(선택적 inbox 비우기 후 typed cause로 abort)는 134–140행; 취소 권한이 Turn을 넘어 새지 않는 설계 기록은 Agent Note 2026-07-16.
왜 오래가는가
명시적 토큰에 스코프 바인딩은 구조화 동시의 통칙예요. Go context, .NET CancellationToken도 같은 길. 토큰은 만든 쪽이 회수하고 자기 스코프를 넘기지 않아 월권이 설 자리가 없습니다. 스트리밍 IO와 외부 프로세스를 함께 멈춰야 하는 한, 다른 언어로 다시 써도 이 명시적 선은 필요합니다.
어떤 문제를 푸는가
취소된 Turn이 종결 상태를 안 쓴다고 가정해 보세요. 로그는 중간에 끊기고, 재생하는 사람은 어떻게 끝났는지 모르고, UI는 어떤 대기 일이 버려졌는지 솔직히 못 말하고, 다운스트림은 질서 있는 중단인지 갑작스러운 죽음인지 구분을 못 합니다. 중단은 정상 업무이고, 장부 없는 중단이 사고예요.
아이디어는 무엇인가
Turn 본문은 try 안에 있어요. catch가 signal.aborted를 보면 결과를 aborted로 두고, finally는 어떻든 turn/end를 로그에 씁니다. 이미 디스크에 있는 스트림 chunk와 도구 출력은 하나도 지우지 않아요.
그래서 로그의 죽는 법은 둘뿐, 각기 서명이 있어요. aborted는 루프가 직접 씁니다—누가 cancel을 호출해 Turn이 질서 있게 마무리. interrupted는 루프가 절대 안 내고, 크래시 복구 때 영속화 백엔드만 합성하는, 루프 밖 유일의 결말. reason만 보면 끝나는 방식이 보입니다.
부가 디테일 둘. 로그는 거친 aborted만 남기고 누가 눌렀는지는 안 남겨요. user/parent는 런타임 정보라 재생에 필요도 없고 알아선 안 됩니다. 비운 대기 메시지에는 turn/end가 없고, foldConsumedWork가 로그를 한 번 훑어 inbox의 outcome: 'canceled'로 droppedUnrun을 계산해야 UI가 일이 버려져 실행되지 않았다고 말할 수 있어요.
출처:catch가 결과를 정하는 곳은 packages/core/agent-loop/src/agent.ts 302–305행, finally의 turn/end는 316–323행, droppedUnrun 접기는 consumed-work.ts 87행.
왜 오래가는가
모든 종료 경로가 종결 상태를 쓰는 것은 로그를 권위 상태로 두는 시스템의 바닥선이고, DB 트랜잭션 로그의 commit/abort와 같은 이치예요. 예외 경로와 정상 경로가 같은 장부를 내야 현장을 재구성하는 사람이 추측하지 않습니다. 모델이 바뀌고 언어를 다시 써도 이 규율은 그대로예요.
어떤 문제를 푸는가
취소는 그래도 finally로 뒷수습이라도 해요. kill -9는 그 기회조차 없습니다. 프로세스가 죽는 순간 로그는 중간에 끊겨요. turn/start는 열려 있고, 어떤 도구는 tool/call만 남긴 채 tool/result를 영원히 못 기다립니다. 재시작 후 이 로그를 콜드 로드하면 선택지가 남아요. 못 끝낸 Turn을 지울까, 맞출까?
지우는 편이 깔끔해 보이지만 대가가 훨씬 큽니다. 장기 작업의 한 Turn은 매우 클 수 있어요—수십 스텝, 대량 도구 출력, 크래시 전에 이미 추가 영속화됐습니다. 절단은 사용자 성과를 함께 묻는 일이고, 데모 시나리오 C가 그 계산이에요.
아이디어는 무엇인가
DSH는 맞추기를 고릅니다. 복구 로직이 결정적 합성 이벤트 몇 개로 꼬리를 닫아요. 먼저 매달린 도구 호출마다 오류 자리 표시 tool/result를 넣고, 열린 step을 닫은 뒤, 마지막으로 turn/end를 합성해 interrupted로 표시합니다. 순서가 중요해요. step이 열린 채 turn/end를 쓰면 로그 불변조건을 깨므로, step 경계를 먼저 맞추고 turn을 맞춥니다.
매달린 도구 호출에 넣는 자리 표시 문구는 두 경우로 갈려요. 시작은 기록됐지만 결과가 디스크에 없으면, 자리 표시는 결과가 알 수 없음라고 말하고 읽기 전용·멱등만 재시도할 수 있으며 부작용이 있으면 외부 상태를 확인하거나 사용자에게 묻게 합니다. 원문은 “Do not retry blindly”를 강조해요. 도구가 아예 시작 안 했으면 필요하면 재시도하라고 직접 말합니다.
“로그를 자르지 않습니다. 장기 작업에서 한 Turn은 매우 클 수 있고(많은 스텝, 대량 도구 출력), 그 이벤트는 크래시 전에 이미 추가 영속화됐습니다. 백엔드는 합성 turn/end { reason: { kind: 'interrupted' } }로 남은 Turn을 닫아, 앞뒤 독립 이벤트를 바꾸지 않고 중단된 실행을 맞춥니다.”
놓치기 쉬운 경계 하나: 이 수리는 콜드 세션에만 적용됩니다. 세션이 살아 있으면 load는 권위 메모리 스냅샷이 디스크에 쓰이길 기다리고 로그가 맞춰질 때만 반환하며, 활성 Turn이 안 닫혀 있으면 거절해 실행 중인 Turn에 합성 경계를 넣지 않아요. 쓰기 쪽도 규율이 있습니다. 영속화 플러그인이 배치로 쓰고, 루프는 다음 Turn을 받기 전에 session/flush로 체크포인트하며 순서와 쓰기 오류를 함께 봅니다.
출처:자리 표시 문구 두 가지는 packages/core/session/src/repair.ts 91–124행, closer 합성 순서(step 다음 turn)는 126–132행, 콜드 세션만 수리는 docs/subsystems/persistence.zh.md 17행, flush 체크포인트는 같은 문서 해당 절.
왜 오래가는가
추가형 로그에 읽기 시 수리는 DB WAL 복구와 같은 발상이에요. 크래시 후 역사를 고치지 않고, 상태 기계가 이어 가도록 최소 이벤트만 보강합니다. 권위 상태가 이벤트 로그에 있는 한, 복구 로직을 몇 번 다시 써도 형태는 같습니다. 반대로 로그를 자르는 시스템은 한 Turn 일이 싸게 버려도 된다고 가정하는 셈인데, 장기 작업 시대에는 그 가정이 성립하지 않아요.
Grok Build
프로세스 급 · 전담 크래시 crateGrok에는 전담 crate xai-crash-handler가 있어요. sigaction으로 SIGSEGV·SIGBUS를 받고, 크래시 현장에서는 시그널 안전 연산만으로 바이너리 스냅샷을 last-crash.bin에 쓰며, 미리 계산한 이스케이프 시퀀스로 터미널도 복구합니다. 다음 기동에서 심볼을 해석해 크래시 리포트를 만들고 최근 5개를 남깁니다(crate README와 handler.rs).
답이 되는 질문은 프로세스가 어떻게 죽었는지, 터미널에 잔해를 남기지 않는 것입니다. DSH의 repair.ts는 세션 로그가 어떻게 살아남는지에 답해요. 각자 한 층을 차지하고, 정면 비교에서 DSH의 독특함은 복구 의미를 영속화 계약에 넣은 점입니다. load 인터페이스 주석이 중단 꼬리를 맞추고 커밋된 이벤트를 고치지 않는다고 직접 약속해요. 확인한 Grok Build 자료에서는 동등한 세션 로그 맞춤 메커니즘을 보지 못했고, 이는 공개 소스 기준입니다.
Claude Code
태스크 급 · AbortController를 task에 연결백그라운드 agent 취소는 killAsyncAgent로 갑니다. 태스크 상태에서 abortController를 꺼내 abort하고 killed로 표시합니다(restored-src LocalAgentTask.tsx 283–298행, 원고 6장 인용). 컨트롤러 수명은 태스크를 따르고, 태스크당 하나입니다.
DSH는 한 단계 더 잘게 자릅니다. 컨트롤러가 Turn을 따르고, 같은 agent의 다음 Turn은 자동으로 새 시그널을 받으며, 옛 콜백이 월권하려 해도 손잡이를 못 찾아요. 또한 DSH는 취소 원인을 영속화하지 않고 durable 로그에 aborted만 남깁니다. Claude Code의 세션 복구·중단 기록 세부는 확인한 원고 장에서 펼쳐지지 않아 여기서는 미지수로 둡니다.
두 시점의 로그 차이를 추론하기
같은 Turn에서 취소가 두 다른 순간에: a) 모델 스트림 중간; b) bash 도구 실행 중.
문제 1: 각각 로그에서 step/start부터 turn/end 사이에 어떤 이벤트가 나오고, turn/end의 reason은 무엇인지 쓰세요.
문제 2: 두 사고 모두 kill -9로 바꾸면, 재시작 후 repair가 closer를 각각 몇 개 합성할까요? 힌트: 스트림 중단에는 매달린 tool/call이 없고, 도구 실행 중단에는 시작이 기록된 tool/call 하나와 결과 알 수 없음 자리 표시 문구가 있습니다.