AI 에이전트가 한도에 막히면 리셋 뒤 직접 메시지를 보내는 일이 번거로웠다. 그래서 앱이 대신 보내는 '재개 예약'을 만들었다. 단, 429나 잔여량 0%만으로 한도 중단을 판단하지 않게 했다.

한도에 막힌 대화를 리셋 뒤 다시 깨우고 싶었다
앱이 추적하는 한도는 Codex·Cursor의 주간 한도와 Codex의 5시간 한도다. 긴 작업이 멈추면 리셋 뒤 내가 '이어서 해 줘'를 보내야 이어졌다.
이번에는 앱 왼쪽 메뉴에 '예약'을 넣었다. 대기 중, 한도 중단, 기록 세 칸으로 나뉜다. 채팅 목록에는 '한도 중단', '재개 가능', '재개 예약됨', '예약 확인 필요' 표시가 붙는다.
한도로 멈춘 채팅은 입력창 위에 '재개 예약' 버튼이 뜬다. 작업 중에 미리 걸어 두려면 입력창 추가 메뉴의 '한도 후 보내기'를 쓴다. 2026년 10월 28일 23시 57분(KST) 커밋으로, Android 2.3.17과 IDE 확장 0.4.26에 들어갔다.
처음엔 '잔여량 0% + 오류'면 한도라고 봤다
삭제된 호출과 테스트 이름으로 보면, 예전에는 잔여량 0% 확인 뒤 2분 안의 오류를 한도 중단으로 봤던 듯하다. 종료 이유가 비어 있어도 그렇게 분류된 것으로 보인다.
잔여량은 계정 전체의 숫자여서 대화가 멈춘 이유를 말해 주지 않는다. 429나 rate limit도 일시적인 제한일 수 있다. 한도로 잘못 분류하면 엉뚱한 대화에 자동 메시지를 보낼 위험이 있었다.
종료 오류에 '사용 한도' 근거가 있을 때만 한도다
그래서 해당 대화의 종료 오류 문구만 한도 판단에 쓰도록 했다. 대화 본문은 보지 않는다. 사용 한도를 분명히 말하는 표현이 있을 때만 한도로 본다.
// UsageLimitReason examines terminal error metadata only, never conversation text.
// A generic 429/rate limit or ResourceExhausted can also mean transient throttling.
func UsageLimitReason(text string) bool {
text = strings.ToLower(text)
for _, token := range []string{"usage limit", "usage_limit", "quota exceeded", "quota_exceeded", "quota has been exceeded", "insufficient_quota", "additional usage is off", "weekly limit", "monthly limit", "사용 한도", "사용량 한도"} {
if strings.Contains(text, token) {
return true
}
}
return false
}
일반 429, rate limit, ResourceExhausted만 있는 오류는 한도로 보지 않는다. 네트워크 오류와 사용자 취소도 마찬가지다. 이유가 비어 있는 종료는 '중단 사유 확인 중'으로 따로 둔다.

case "error":
job.Status = model.StatusAborted
detail := strings.TrimSpace(body.ErrorMessage + " " + body.Reason)
job.StopKind = "error"
if UsageLimitReason(detail) {
job.Status = model.StatusQuotaStopped
job.StopKind = "quota"
} else if detail == "" {
job.StopKind = "checking"
}
예약은 원래 실행과 같은 계정에만 묶인다
예약은 고유 ID와 함께 원래 실행 ID를 기억한다. 서버는 그 PC·프로필에 연결된 계정의 한도만 본다. 다른 PC나 다른 계정의 잔여량을 대신 쓰지 않는다.
사용량 정보가 없거나 2분보다 오래됐으면 예약을 지우지 않고 '확인 대기'로 남긴다. 그사이 다른 실행이 시작되거나 계정이 바뀌면 예약은 일시 정지되고 '예약 확인 필요'가 된다. armed·checking 예약은 완료·중단·수동 전송 때 취소되지만, 이유 없는 중단은 먼저 확인 대기로 남는다.
상태는 armed에서 checking 또는 held를 거쳐 sending, 마지막에 sent나 failed로 간다. 한도 근거가 확인되면 held가 되고, 그 한도의 리셋 시각을 함께 저장한다.

리셋 뒤에도 보내기 직전에 한 번 더 확인한다
리셋 시각이 지났다고 바로 보내지 않는다. 묶어 둔 한도마다 다음 주기의 리셋 시각과 양수 잔여량이 실제로 보여야 복구로 본다. 같은 계정에 새로 0%가 된 한도가 있으면 계속 기다린다.
if h.Status != Held || !online || !idle || !canSend || !recovered(h.Pools, pools, now) {
return false
}
// … 같은 계정에 새로 0%가 된 한도가 있으면 계속 기다린다
if p.Source == h.Source && (h.AccountID == "" || p.AccountID == h.AccountID) && Window(h.Source, p) && blocked(p) {
return false
}
// … recovered(): 묶어 둔 한도마다 리셋 시각이 지났고, 다음 리셋 시각과 양수 잔여량이 보여야 한다
if !p.Available || p.Remaining == nil || *p.Remaining <= 0 || p.ResetsAt == nil || !p.ResetsAt.After(b.ResetsAt) || !p.ResetsAt.After(now) || b.ResetsAt.After(now) {
return false
}
PC가 온라인이고 원래 대화가 아직 한도 중단 상태이고 보낼 수 있어야 sending으로 넘어간다. 서버가 보내기로 정해도 확장이 명령을 가져가기 직전에 다시 확인한다. 이 확인을 지원하지 않는 구버전 확장은 명령을 가져갈 수 없다.
Codex는 원본 대화를 다시 읽는다. 마지막 실행이 예약한 실행과 같고 진행 중이 아닐 때만 기존 큐에 넣는다.
err := c.broker.call(ctx, "thread/read", map[string]any{"threadId": input.Thread, "includeTurns": true}, &previous)
turns := previous.Thread.Turns
if err != nil || previous.Thread.ID != input.Thread || previous.Thread.Status.Type == "active" || len(turns) == 0 || turns[len(turns)-1].ID != input.ResumeRunID || turns[len(turns)-1].Status == "inProgress" {
http.Error(w, "resume run changed", 409)
return
}
Cursor는 진행 중인 실행이 없는지, 원본 generation(한 번의 응답 실행)이 예약한 실행과 같은지 확인한다. 둘 다 결과가 불확실하면 자동으로 다시 보내지 않는다.

const state = await selection.verify(id, true);
// … 미전송 초안이나 진행 중인 응답이 있으면 여기서 먼저 거절한다
if(resumeRun&&state.generation_id!==resumeRun)throw Error("예약한 실행 이후 대화가 바뀌어 전송하지 않았습니다");
예약 ID로 요청 ID를 만들어 중복 전송을 막았다. 서버가 재시작해도 같은 예약은 두 번 가지 않는다. 수동 전송 접수와 기존 예약 취소도 같은 DB 트랜잭션에서 처리한다.
검증은 아래처럼 했다. 같은 커밋의 화면 갱신 최적화와 Cursor 도구 카드 수정은 다음 글들에서 따로 다룬다.
- Android 단위 테스트 110개, 확장 Node 테스트 109개
- 실제 휴대폰 계측 테스트 6개, 에뮬레이터 테스트 31개와 접근성 추가 2개
- 한도 소진과 리셋 시각은 임시 DB와 테스트 시계로 재현, 실제 계정 한도는 소진하지 않음
이번에 배운 것
- 429는 '느려져라'일 수 있다. 한도는 근거 문구로만 판단한다.
- 계정 전체 숫자로 한 대화의 사정을 짐작하지 않는다.
- 자동 전송은 원래 실행일 때만, 불확실하면 보내지 않는다.
다음 할 일은 실제 한도 리셋 때 예약이 제시간에 이어지는지 운영 기록으로 확인하는 것이다.
자주 묻는 질문
Q. 작업 중에 걸어 둔 예약은 한도가 아닌 오류로 멈추면 어떻게 되나?
A. 이유가 적힌 다른 오류로 끝나면 예약은 취소된다. 이유가 비어 있으면 '중단 사유 확인 중'으로 남아 근거를 기다린다.
Q. 리셋 시각이 지났는데 왜 바로 안 보내나?
A. 리셋 시각만으로는 복구를 확신할 수 없어서다. 최신 사용량에서 다음 주기와 양수 잔여량이 보여야 보낸다.
관련 글: 작업 중 메시지는 예약으로, 입력창 버튼 하나가 녹음·전송·중단을 오갔다 (바로 앞 글)
참고: MDN, 429 Too Many Requests · OpenAI Codex 공식 문서, App Server(thread/read)
'프로젝트 > AI 작업 모니터' 카테고리의 다른 글
| Cursor 작업이 끝났는데 도구 카드 3개가 계속 돈 이유, 두 개의 호출 ID (0) | 2026.10.30 |
|---|---|
| 백그라운드 SSE 1.2MB를 65KB로, 안 보이는 화면은 조회를 멈췄다 (0) | 2026.10.29 |
| 작업 중 메시지는 예약으로, 입력창 버튼 하나가 녹음·전송·중단을 오갔다 (0) | 2026.10.29 |
| 채팅 전송 '실행 중' 멈춤, 대화 읽기 전에 '안 보냄'부터 기록했다 (0) | 2026.10.29 |
| Compose 채팅 첫 화면 개편, PC 폴더 500개 넘으면 커서로 이어 받았다 (0) | 2026.10.28 |