본문 바로가기
프로젝트/AI 작업 모니터

채팅 전송 '실행 중' 멈춤, 대화 읽기 전에 '안 보냄'부터 기록했다

by iwbap 2026. 10. 29.
728x90
반응형

휴대폰에서 보낸 Codex 요청 하나가 서버에서 '실행 중'으로 바뀐 뒤 결과 없이 멈춰 있었다. 원인은 IDE 확장이 전송 기록을 남기기 전에 대화 기록부터 읽었고, 그 읽기가 실패하면 아무것도 남지 않는 구조였다. 기록을 먼저 남기고 앱이 이전 말풍선도 관찰하게 고쳤다.

어두운 배경에 채팅 전송 실행 중 멈춤, 대화 읽기 전에 안 보냄부터 기록했다는 표지 문구와 재시도는 읽기와 보고까지, 실행은 한 번이라는 부제, 이전에는 기록 전에 대화를 읽다 실패하면 요청이 결과 없이 남았다는 설명과 준비 단계부터 receipt 저장, 이전 말풍선도 각각 관찰이라는 해결 핵심어가 있고, 오른쪽에는 첫 번째 전송은 실패, 두 번째 전송은 완료로 표시된 말풍선과 준비 단계는 failed, 전송 직전은 unknown이라고 적힌 receipt 상자, 메시지 입력창이 놓인 휴대폰 화면 예시가 있는 대표 이미지
보냈는지 모르는 순간이 오기 전에 '안 보냈다'부터 남긴다

결과 없이 '실행 중'에 남은 요청 하나

2026년 10월 29일 06시 06분(KST), 운영 중인 Codex 요청 하나가 실행 중으로 바뀌었다. 그런데 결과 보고도, 확장의 전송 기록(receipt)도 없었다. 바로 다음 요청은 정상으로 접수됐다.

같은 대화에는 약 1분 뒤 컨텍스트 요약 기록이 있었다. 시간은 겹치지만 당시 읽기 예외 로그가 없어서 요약이 원인이라고 단정하지는 않았다. 대신 요약 중에 생길 수 있는 읽기 실패를 기준으로 코드를 다시 봤다.

수정은 같은 날 08시 26분(KST) 반영됐다. Android 앱 2.4.1과 IDE 확장 0.4.27이다.

기록보다 읽기가 먼저였다

확장은 서버에서 명령을 가져온 뒤 대화 기록 100개를 읽고 나서야 receipt를 저장했다. 이 읽기는 예외 처리 바깥에 있었다. 읽기가 실패하면 receipt도 결과 보고도 없이 서버만 접수 단계에 남는다.

앱에도 빈틈이 있었다. 새 메시지를 보내면 이전 요청의 상태 관찰을 멈췄다. 화면에 돌아와 이미 같은 완료·실패 상태를 받아도, 상태가 '바뀌지 않았다'는 이유로 로컬 말풍선은 갱신하지 않았다.

전송 준비부터 '보내지 않음'으로 기록

순서를 뒤집었다. 명령을 가져오면 대화를 읽기 전에 receipt부터 저장한다. 첫 결과는 failed, "전송 준비가 중단되어 IDE에 보내지 않았습니다"다. 이 단계에서 재시작돼도 '확실히 안 보낸 요청'으로 남는다.

IDE 확장의 전송 처리 순서를 이전과 이후로 비교한 흐름도, 이전에는 서버에서 명령을 가져온 뒤 예외 처리 밖에서 대화 기록 100개를 읽고 나서야 receipt를 저장하고 IDE 전송과 결과 보고를 해서 읽기가 실패하면 receipt도 결과 보고도 없이 서버에 실행 중인 요청만 남았고, 이후에는 명령을 가져오자마자 보내지 않았다는 failed 상태의 receipt를 저장하고 대화 읽기는 일시 오류일 때만 최대 세 번 시도하며 IDE에 넘기기 직전 unknown을 저장한 뒤 IDE 전송은 한 번만 하고 결과를 보고한다, 아래에는 타임아웃과 종료 신호는 다시 읽지 않고 준비 중 실패는 failed로 보고하며 보고가 끊겨도 재시작 뒤 저장된 기록으로 다시 보고한다는 설명이 있다

// … 서버에서 명령을 가져온 직후. 예전에는 여기서 대화 기록부터 읽었다
// Claiming the server command already makes it non-replayable. Journal
// it before reading history too: compaction can make that read fail.
// A restart during preparation is a known non-send, not an orphaned run.
// … receipt 첫 결과: failed "전송 준비가 중단되어 IDE에 보내지 않았습니다…"
this.receipts.push(receipt); await this.save();
let attempted=false;
try {
    let baseline:Page|undefined;
    for(let attempt=0;!baseline;attempt++) {
        try {baseline=await this.helper.call<Page>("page",{conversation_id:c.id,before:"",limit:100})}
        catch(error) {
            if(attempt>=2||this.abort.signal.aborted||/timeout|stopped|종료/.test(String(error)))throw error;
            // Only the read retries. Never repeat a native send whose
            // acknowledgement may have been lost during compaction.
            await pause(250*(attempt+1),this.abort.signal);
        }
    }

대화 읽기만 일시적인 오류일 때 최대 세 번 시도한다. 타임아웃이나 종료 신호는 다시 시도하지 않는다. 테스트에서는 첫 읽기에 SQLITE_BUSY 오류를 던지고 IDE 전송이 한 번만 일어나는지 확인했다.

읽기와 첨부 준비가 끝나면 IDE에 넘기기 직전에 결과를 unknown으로 바꿔 저장한다. 여기서부터는 IDE가 받았는지 알 수 없으니, 재시작해도 같은 메시지를 다시 실행하지 않는다.

// … 읽기·첨부 준비가 끝난 뒤, IDE에 넘기기 직전
receipt.outcome={status:"unknown",detail:"전송 중 중단됐습니다. 원본 대화를 확인하세요. 자동 재전송하지 않습니다."};
await this.save();
// …
attempted=true;receipt.outcome=await this.preparation.action(()=>this.adapters.send(c,directed));

준비 중 실패는 failed로 보고한다. 보고 통신이 끊겨도 저장된 기록으로 재시작 뒤 다시 보고한다. 다시 시도하는 것은 읽기와 결과 보고뿐이고 IDE 전송은 아니다. 이 원칙은 폰에서 IDE로 보낸 지시, 응답이 사라져도 두 번 실행하지 않게에서 receipt를 처음 만들 때와 같다.

전송 단계별로 저장된 결과와 재시작이나 실패 뒤 행동을 정리한 표 형태의 도식, 명령을 가져온 뒤 대화 읽기와 첨부 준비까지는 보내지 않았음을 뜻하는 failed가 저장되어 IDE에 보내지 않았다고 보고하고 사용자가 확인 후 다시 보내며, IDE에 넘기기 직전부터는 받았는지 알 수 없음을 뜻하는 unknown이 저장되어 자동 재전송 없이 원본 대화를 확인하게 하고, IDE 전송 결과를 받으면 submitted나 delivered 같은 실제 결과를 보고하고 보고가 끊기면 저장된 기록으로 다시 보고한다, 맨 아래에는 다시 시도하는 것은 대화 읽기와 결과 보고이고 다시 시도하지 않는 것은 IDE 전송이라는 문구가 있다

앱은 이전 말풍선까지 각각 지켜본다

앱은 최신 요청뿐 아니라 로컬 말풍선으로 남은 이전 요청도 각각 관찰한다. 먼저 바꾼 것은 상태가 같아도 말풍선에 반영하는 한 줄이다.

-                if(result.status!=after&&localRequestId.isNotBlank()) onLocalMessage(localRequestId,"",result.status,emptyList())
+                        if(request.isNotBlank())onLocalMessage(request,"",result.status,emptyList())

요청마다 따로 관찰 작업을 띄운다. 저장해 둔 명령과 요청 ID가 정확히 짝지어진 말풍선만 갱신한다. 다른 기기에서 보낸 최신 명령이 엉뚱한 말풍선을 바꾸지 않게 하려는 것이다.

// New sends replace the composer status, but every older optimistic
// bubble must still receive its own result, including after navigation.
val tracked=commandLocalIds.toMap()
val commands=(tracked.keys+listOf(commandId).filter {it.isNotBlank()}).distinct()
kotlinx.coroutines.coroutineScope {
    for(cmd in commands) launch {
        // The latest command may have been sent by another client.
        // Only an exact saved command/request pair can update a bubble.
        val request=tracked[cmd].orEmpty()
        // … 35분 동안 이 명령의 상태를 기다리며 말풍선에 반영, 끝나면 관찰 종료
        if(result.terminal){commandLocalIds.remove(cmd);return@launch}
        // … 제한 시간이 지나면
        if(request.isNotBlank())onLocalMessage(request,"","unknown",emptyList())
    }
}

완료·실패·확인 필요 결과가 오면 관찰을 끝낸다. 이전 말풍선이 원본 메시지로 대체돼도 조회를 멈춘다. 35분 확인 제한이 지나면 말풍선도 unknown으로 바꾼다. 예약 취소·지금 반영과 겹친 오래된 응답은 적용하지 않는다.

휴대폰 앱의 말풍선 상태 관찰을 이전과 이후로 비교한 도식, 이전에는 두 번째 메시지를 보내면 첫 번째 전송의 관찰이 멈춰 말풍선이 최종 상태로 바뀌지 않을 수 있었고 두 번째 전송만 관찰하며 화면에 돌아와 같은 상태를 받으면 바뀌지 않았다며 말풍선을 갱신하지 않았고, 이후에는 첫 번째와 두 번째 전송을 따로 관찰해 각각 실패와 완료를 반영하고 같은 상태도 반영하되 명령과 요청 ID가 정확히 짝지어진 말풍선만 갱신한다, 아래에는 완료와 실패와 확인 필요 결과가 오거나 원본 메시지로 대체되면 관찰을 끝내고 35분이 지나면 unknown으로 바꾸며 예약 취소나 지금 반영과 겹친 오래된 응답은 버린다는 설명이 있다

재전송 없이 복구됐는지 검증

회귀 테스트에서는 두 번 전송한 뒤 설정 화면을 거쳐 대화로 돌아왔다. 그사이 첫 요청은 실패, 두 번째는 완료로 바뀐다. 두 말풍선의 결과와 전송 횟수 2회를 확인했다.

// … 두 메시지를 연달아 보내고 설정 화면으로 이동한 사이
statuses["command-1"]="failed";statuses["command-2"]="completed"
// … 대화로 돌아와 두 말풍선이 각각 failed·completed가 될 때까지 기다린 뒤
compose.onAllNodesWithText("전송 중").assertCountEquals(0)
compose.onNodeWithText("작업 완료").assertExists()
assertEquals("Status recovery must not resend either prompt",2,attempts.size)
  • 확장 Node 테스트 113개: 신규 3개는 요약 중 일시 읽기 오류, 준비 실패 뒤 보고 끊김·재시작, 첫 읽기 중 재시작
  • Android 단위 테스트 127개, debug·AndroidTest 빌드 성공
  • 에뮬레이터 UI 테스트 12개, 최종 빌드에서 관련 5개 재검증

실제 컨텍스트 요약을 강제로 일으키거나 내 대화에 테스트 메시지를 보내지는 않았다. 멈춰 있던 요청 하나는 원문과 SQLite 백업을 남긴 뒤 unknown으로 정리했고 다시 실행하지 않았다. 실제 휴대폰 설치 검증은 아직이다.

이번에 배운 것

  • 보냈는지 모르는 순간이 오기 전에 '안 보냈다'부터 기록한다.
  • 재시도는 읽기와 보고까지만, 실행은 한 번이다.
  • 상태가 같다는 건 화면이 맞다는 뜻이 아니다.

다음에는 새 앱을 실제 휴대폰에 설치해 확인할 것이다.

자주 묻는 질문

Q. 읽기가 세 번 다 실패하면 메시지는 어떻게 되나?
A. IDE에 보내지 않은 채 failed로 보고된다. 결과 설명은 "IDE에 보내지 않았습니다."로 시작하니, 내용을 확인한 뒤 다시 보내면 된다.

Q. 결과가 불확실하면 왜 자동으로 다시 보내지 않나?
A. IDE가 이미 받았을 수 있어서다. 다시 보내면 같은 작업이 두 번 돌 수 있으니 unknown으로 남기고 원본 대화를 확인하게 했다.

관련 글: 폰에서 IDE로 보낸 지시, 응답이 사라져도 두 번 실행하지 않게 (receipt를 처음 만든 글), Compose 채팅 첫 화면 개편, PC 폴더 500개 넘으면 커서로 이어 받았다 (바로 앞 글)
참고: Kotlin 공식 문서, 코루틴 기초(coroutineScope와 launch) · MDN, 멱등성(Idempotent) 용어 설명

728x90
반응형