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

결과 없이 '실행 중'에 남은 요청 하나
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에 보내지 않았습니다"다. 이 단계에서 재시작돼도 '확실히 안 보낸 요청'으로 남는다.

// … 서버에서 명령을 가져온 직후. 예전에는 여기서 대화 기록부터 읽었다
// 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를 처음 만들 때와 같다.

앱은 이전 말풍선까지 각각 지켜본다
앱은 최신 요청뿐 아니라 로컬 말풍선으로 남은 이전 요청도 각각 관찰한다. 먼저 바꾼 것은 상태가 같아도 말풍선에 반영하는 한 줄이다.
- 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으로 바꾼다. 예약 취소·지금 반영과 겹친 오래된 응답은 적용하지 않는다.

재전송 없이 복구됐는지 검증
회귀 테스트에서는 두 번 전송한 뒤 설정 화면을 거쳐 대화로 돌아왔다. 그사이 첫 요청은 실패, 두 번째는 완료로 바뀐다. 두 말풍선의 결과와 전송 횟수 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) 용어 설명
'프로젝트 > AI 작업 모니터' 카테고리의 다른 글
| 사용 한도 자동 재개 예약, 429만으로는 한도라고 판단하지 않았다 (0) | 2026.10.29 |
|---|---|
| 작업 중 메시지는 예약으로, 입력창 버튼 하나가 녹음·전송·중단을 오갔다 (0) | 2026.10.29 |
| Compose 채팅 첫 화면 개편, PC 폴더 500개 넘으면 커서로 이어 받았다 (0) | 2026.10.28 |
| Cursor 창 자동 종료, 앱이 연 창만 닫고 느린 시작은 240초 기다렸다 (0) | 2026.10.28 |
| IDE 새 대화 중복 생성, 결과를 모르면 다시 만들지 않았다 (0) | 2026.10.28 |