멈춘 세션 복구
원인과 외부 효과를 확인한 뒤 복구 결정을 적용한다
무엇을 하게 되나
복원 실패·시작 실패·결과 불명의 turn을 구분하고 외부 효과를 확인한 뒤 명시적인 복구 결정을 적용한다.
준비물
세션 상세와 worker·scheduler·reconciler 로그, sessions:recover scope의 API key가 필요하다. 마지막 checkpoint와 외부 도구 효과를 확인한다. 저장소가 회복됐다고 outcome_unknown turn을 자동 재실행하지 않는다.
단계
1. 감독 루프와 일시 장애 구분
reconciler 대상만 먼저 보려면 dry-run을 직접 1회 실행한다. compose의 반복 reconciler에는 dry-run 환경 변수가 전달되지 않는다.
RECONCILER_CONTAINER='<this-installation-reconciler-container-id>'
docker exec -e RECONCILER_DRY_RUN=true "$RECONCILER_CONTAINER" \
bun run apps/control-host/src/main.ts reconciler --once
docker exec -e RECONCILER_DRY_RUN=false "$RECONCILER_CONTAINER" \
bun run apps/control-host/src/main.ts reconciler --once컨테이너는 이번 설치의 status와 project label로 확인한다. 컨테이너의 실제 설치 env를 사용하며 개발용 DB 주소를 넣지 않는다. 두 번째 명령은 DB를 실제로 바꾸므로 첫 명령 결과를 검토한 뒤에만 실행한다.
최근 결과는 같은 컨테이너의 /tmp/reconciler-status.json과 Reconciler pass completed·Reconciler pass failed 로그로 본다. reconciler에는 Docker socket이 없고 epoch fence와 desired_state = terminated를 DB에 기록한다. 실제 컨테이너 제거·부재 확인은 scheduler가 하므로 reconciler pass 성공을 컨테이너 제거 완료로 판단하지 않는다. 겹친 pass의 각 쓰기는 row lock 아래에서 다시 판정하여 같은 lease·interrupt·orphan을 두 번 처리하지 않는다.
격리 계약 변경을 적용할 때
Docker daemon을 먼저 28 이상으로 올린 뒤 격리 계약을 바꾼 서버 이미지를 적용한다. 옛 daemon에서는 preflight가 pass 전체를 막아 기존 worker도 교체하지 못한다. claim된 worker는 최대 MAX_TURN_SECONDS + 5분 동안 drain하므로 그동안 옛 계약 컨테이너와 대기 입력이 남는다. 즉시 끊어야 하는 격리 결함이면 영향 세션을 terminate한다. 롤백으로 컨테이너가 current가 되어도 drain 요청은 남고 새 turn 없이 종료한 뒤 다음 launch로 이어진다.
2. checkpoint 복원과 시작 실패
checkpoint 복원이 계속 실패하는 세션
복원을 넘겨받은 worker가 ready를 보고하기 전에 끝나면 scheduler는 곧바로 다시 띄우지 않는다. 이런 종료가 두 번째까지는 30초, 60초를 기다린다. 세 번째에는 세션이 recovery_required로 멈추고 새 generation을 만들지 않는다. 기다리는 동안이나 멈춘 뒤의 세션 상세 attention은 RESTORE_FAILED다.
reason: 마지막 worker가 남긴 오류다. release 없이 죽었으면execution_gone이다.failures: 이어진 실패 횟수다.retry_at: 다음 시도 시각이다. 멈춘 뒤에는 null이다.
INCOMPATIBLE_CHECKPOINT는 재시도해도 같은 worker runtime·profile에서 성공할 수 없는 결정적 실패다. 이 경우 첫 launch 뒤 바로 recovery_required로 멈추며 attention과 checkpoint_restore_failed 이벤트의 reason은 incompatible_checkpoint, failures는 1, retry_at은 null이다. 운영자는 호환 runtime·profile로 되돌린 뒤 retry_restore로 같은 checkpoint를 다시 시도하거나, 이전 context를 버려도 되면 start_fresh를 선택하거나, 세션을 끝내려면 close를 선택한다.
이벤트 스트림에는 실패마다 system checkpoint_restore_failed가 남는다. worker 로그의 worker.checkpoint.restore_refused, worker.failed에서 원인을 확인한다. 저장소 응답 checksum 불일치(전송 중 손상)도 CHECKPOINT_UNAVAILABLE로 분류된다.
- 저장소나 egress-proxy
/object-store경로가 잠시 답하지 못한 것은 실패로 세지 않는다. 연결 실패·DNS 실패·request/body timeout·잘린 body와 HTTP 408·429·5xx가 일시 장애다. checkpoint 복원은worker.checkpoint.restore_unavailable, checkpoint가 아직 없는 새 generation의 transcript 저장소 확인은worker.checkpoint.store_unavailable경고를 남긴다. 둘 다 같은 worker 안에서 1초부터 두 배씩, 최대 30초 간격으로WORKER_STARTUP_TIMEOUT_SEC(기본 3600초)까지 다시 시도한다. 저장소가 돌아오면 그 worker가 이어가므로 generation을 새로 만들지 않는다. - 시작 예산이 끝날 때까지 장애가 계속되면 그 worker 종료를 시작 실패 1회로 센다. 다음 generation은 30초, 60초의 세션 backoff 뒤에만 뜨고 세 번째 실패에서
recovery_required로 멈춘다. 따라서 저장소 장애가 worker의 무제한 즉시 재기동으로 바뀌지 않는다.WORKER_STARTUP_TIMEOUT_SEC는 scheduler가 worker에 별도 값을 넘기지 않으므로 현재 배포에서는 코드 기본값으로 고정이다. - 객체가 실제로 없거나(404/
NoSuchKey), 401·403으로 거절되거나, checksum·digest가 맞지 않는 경우는 일시 장애가 아니다. manifest나 manifest가 가리키는 객체가 없으면CHECKPOINT_UNAVAILABLE이며 재시작으로 복구됐다고 간주하지 않는다. bucket 전체가 비었는데 DB pointer가 남은 경우는 API 기동 검사가 먼저 막는다.restore_refused는 저장소 경로가 아니라 checkpoint의 영구 손상·불일치 쪽을 조사하라는 신호다. - 원인이 세션 밖에 있었다면(프록시의 전송 중 손상, 저장소 경로 설정 오류) 그것을 고친 뒤
retry_restore로 같은 checkpoint를 다시 복원한다. 횟수가 0으로 돌아가고 queued 입력이 다시 신호된다. 다음 worker는 실패하던 worker와 같은 pointer에서 복원 계획을 다시 받는다.
# KEY: an API key with the sessions:recover scope
curl -X POST "$API/v1/sessions/$SESSION/recovery-decisions" \
-H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"decision":"retry_restore","expected_revision":<revision>,"reason":"proxy fixed"}'한도에 닿아 멈춘 세션에만 받는다. backoff 중이거나 다른 이유(context gap, unknown turn, 복원 없는 STARTUP_FAILED)로 멈춘 세션은 409 REQUEST_STALE이다.
- checkpoint를 포기해도 되면
start_fresh로 이어간다. checkpoint 없이 새 engine session이 시작된다. - 세션을 끝내려면
close를 쓴다.
시작 단계에서 계속 죽는 세션
checkpoint가 없는 세션도 같은 규칙을 따른다. worker가 claim 뒤 입력을 한 번도 청하지 않고 끝나면 실패 1회다. workspace 준비(git clone·fetch)나 엔진 기동에서 죽은 경우다. 두 번째까지는 30초, 60초를 기다리고, 세 번째에는 recovery_required로 멈춘다. attention은 STARTUP_FAILED이고 reason·failures·retry_at은 위와 같다. 이벤트 스트림에는 실패마다 system startup_failed가 남는다.
- 흔한 원인은 저장소 URL·branch가 사라졌거나 저장소 자격 증명이 거부된 경우, 그리고 worker의 object store·proxy 설정 오류다. worker 로그의
worker.stopping kind=failed,worker.failed에서 확인한다. - 원인을 고친 뒤
start_fresh로 다시 띄운다. 이 세션에는 버릴 checkpoint가 없으므로 잃는 것이 없다. 세션을 끝내려면close를 쓴다. - pause, terminate, close, 실행 권한 회수로 끝난 worker는 세지 않는다.
- SIGTERM으로 drain된 worker도 세지 않는다. worker는 release에
stop_kind: "drain"을 싣는다. 이 필드를 모르는 옛 API는 strict schema라 400BAD_REQUEST로 거절한다. 그러면 worker는worker.release.stop_kind_refused를 로그에 남기고 필드 없이 한 번 더 release한다. 세션은 lease 만료를 기다리지 않고 바로 돌아온다. 다만 옛 API는 그 종료를 예전 규칙대로 센다. 복원이 걸린 claim이면 실패 1회다. - 배포 순서: 제어 호스트 이미지(
API_IMAGE, api·scheduler 공용)를 먼저 올리고WORKER_IMAGE는 그다음에 바꾼다. worker protocol의 요청 schema는 strict라서 새 worker가 보낸 새 필드를 옛 API가 400으로 거절한다. 대체 경로가 있는 것은 release, pending-control의reason·answer_result,platform_tool_trust이고(옛 API가 거절하면 worker가 그 필드를 빼고 한 번 다시 보낸다), 그 밖의 새 필드에는 없다.
3. interrupt 뒤 결과 불명
interrupt 뒤 결과를 알 수 없는 turn
interrupt한 turn이 interrupted로 끝나려면 engine이 멈췄다는 응답과 그 turn까지 덮는 checkpoint가 함께 있어야 한다. 둘 중 하나라도 없으면 transcript에 무엇이 남았는지 아무도 말할 수 없다. 그래서 turn은 outcome_unknown이 되고, turn 조회(GET /v1/sessions/{id}/turns/{turn_id})의 terminal_reason이 원인을 말한다.
interrupt_checkpoint_unavailable: engine은 멈췄지만 checkpoint를 커밋하지 못했다. worker는 drain으로 내려간다.interrupt_unanswered: engine이 interrupt에 제때 답하지 않았다. worker 로그에gave no terminal within …ms of its interrupt또는ended before its interrupt was answered가 남고, worker는 실패로 끝난다.
worker 로그의 worker.turn.interrupt.stage를 같은 session_id·turn_id·control_id로 모으면 control_received → sdk_interrupt_called → sdk_interrupt_answered → engine_terminal → capture_started → capture_finished → finalize_started → finalize_finished 순서와 interrupt 수신 뒤 elapsed_ms를 볼 수 있다. 최종 finalize 뒤에는 control.result가 operation=interrupt, 최종 turn outcome, 안정된 reason_code, durable 수락 시각부터 turn terminal까지의 effect_ms(duration_ms와 같음)를 남긴다. 이미 끝난 turn에 대한 no-op은 수락 transaction 직후 effect_ms=0으로, 기한까지 끝나지 않은 receipt는 outcome=unknown·reason_code=deadline_exceeded로 같은 event를 남긴다. SDK 응답이 engine terminal 뒤에 도착하면 정상 순서에 끼워 넣지 않고 late_sdk_interrupt_answered로 기록한다. engine_terminal outcome=timeout은 5초 grace 안에 SDK/engine terminal이 오지 않은 경우다. capture_finished outcome=timeout과 budget_ms=30000은 checkpoint 캡처 예산을 쓴 경우이고, outcome=failed의 reason은 저장소 요청 같은 캡처 자체의 실패다. 캡처가 끝났어도 finalize_finished outcome=failed에 CHECKPOINT_UNAVAILABLE이 있으면 API가 manifest나 그 ref를 거절한 경우이므로 캡처 시간 초과나 S3 요청 실패로 분류하지 않는다. 5초 grace는 바꾸지 않는다. timeout이면 다음 입력을 같은 engine에 주지 않고 worker를 실패로 내려 복구 결정을 요구한다.
어느 쪽이든 세션은 status: failed, admission_state: recovery_required가 되고 queued 입력은 dispatch되지 않는다. interrupt receipt는 unknown이고 error는 RECOVERY_REQUIRED다. 복구는 sessions:recover scope로 한다.
저장소가 다시 정상이어도 outcome_unknown turn은 자동 재실행하지 않는다. 저장소 복구는 다음 worker가 checkpoint를 읽을 수 있게 할 뿐, 이미 실행된 turn의 외부 효과를 판정하지 못하기 때문이다. 아래 abandon·confirm_completed·close 가운데 운영자가 고른 결정만 queue head를 정리한다.
abandon·confirm_completed는 worker가 사라진 것이 확인된 뒤에만 받는다. 그 전에는 409RECOVERY_REQUIRED(The previous execution has not been confirmed gone)다. 다음 scheduler pass가 컨테이너 부재를 확인하면 받는다.close는 기다리지 않는다. 남은 execution에 종료를 요청하고 바로 받는다.- 그 turn의 도구가 바깥에 한 일을 확인한다. 파일 쓰기, push, 외부 호출은 되돌려지지 않는다.
- 결정을 보낸다.
# KEY: an API key with the sessions:recover scope
curl -X POST "$API/v1/sessions/$SESSION/recovery-decisions" \
-H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"decision":"abandon","expected_revision":<revision>,"reason":"interrupt had no confirmed effect","target_turn_id":"<turn_id>"}'abandon: turn을cancelled로 닫는다.confirm_completed: 커밋된 checkpoint가 그 turn까지 덮을 때만 받는다(evidence_ref필수).interrupt_checkpoint_unavailable은 대개 그런 checkpoint가 없으므로 409CHECKPOINT_UNAVAILABLE이다.close: 세션을 끝낸다. 되돌릴 수 없다.
abandon·confirm_completed뒤 세션은stopped다. 결정 receipt의result.resumable이true면 resume한다.false면 이어받을 checkpoint가 없거나, 마지막 checkpoint 뒤에 돈 turn이 있어 그 context를 이어받을 수 없다는 뜻이다(세션 상세 attentionCONTEXT_GAP).start_fresh로 그 context 없이 이어가거나close한다.
4. stopped workspace 회수와 resume
stopped TTL 계약에 따라 volume은 만료될 수 있다. committed checkpoint가 없는 stopped 세션은 resume할 수 없고 volume에만 있던 작업은 만료 뒤 사라진다. 필요한 파일은 TTL 안에 보존한다. 회수 claim 중에는 resume이 503 BACKEND_UNAVAILABLE, retryable true이며 receipt·idempotency 기록을 남기지 않는다. 회수가 끝난 뒤 같은 요청을 재시도한다.
확인
결정 receipt의 상태와 세션 revision·admission·resumable을 함께 확인한다. 수락은 worker 부재나 실제 재개 성공과 다르다. 재개 후 새 turn이 checkpoint의 context를 이어받았는지 별도로 확인한다.
자주 막히는 곳
원인이 그대로인 incompatible checkpoint는 같은 runtime·profile로 재시도해도 성공하지 않는다. start_fresh는 이전 context를 버린다. confirm_completed는 해당 turn까지 덮는 커밋 checkpoint와 evidence_ref가 필요하다.
되돌리기
외부 파일 쓰기·push·호출은 복구 결정으로 되돌아가지 않는다. close는 되돌릴 수 없다. 원인을 고치기 전 반복 결정이나 새 worker 강제 실행으로 덮지 않는다.