자동화를 한 번 시작한 다음에도 기다리는 동안 에이전트가 계속 개입하면 대화가 길어진다. 나는 로컬 대기를 별도 책임으로 떼어, 새 요청을 보내는 기능 없이 기존 실행만 읽는 합성 모듈을 만들었다. 시간 초과가 났다는 사실과 원격 작업이 실패했다는 판단도 분리한다.

시작하는 함수부터 넣지 않았다

wait_existing이 받는 것은 이미 알고 있는 run_id와 신뢰할 scope, 상태를 읽는 함수다. 실행 시작이나 재시도, 업로드나 로그인 함수는 없다. 대기를 편하게 만들려다 타임아웃 뒤 같은 작업을 다시 시작하는 기능까지 넣으면 중복 전달의 원인이 될 수 있어서다. 새로운 실행이 필요한지는 이 함수 밖의 판단으로 남겼다.

상태 어댑터는 실행 ID 하나를 받아 같은 ID의 영수증을 돌려줘야 한다. 함수 이름이 read라고 실제로 읽기 전용이 보장되는 것은 아니다. 연결하는 개발자가 실제 인증과 API 메서드를 검토해야 한다. 이 합성 모듈은 임의 콜백의 행동을 통제하는 보안 샌드박스가 아니다.

짧은 상태 영수증에 필요한 값만 받았다

영수증은 id·scope·status·verified만 받는다. 실행 ID와 profile·project가 기대한 값과 다르면 즉시 멈춘다. 큰 원고나 페이지 전체가 상태 응답에 붙어 오면 알 수 없는 필드로 거절한다. 기다리는 동안 반복해서 전달할 자료를 작게 유지하는 계약을 코드로 드러냈다.

status가 succeeded여도 verified가 실제 참값이어야 결과를 돌려준다. 정수 1이나 문자열을 참으로 취급하지 않는다. 실패·취소·확인 필요는 각각 오류 코드로 끝내고 자동 재전송을 거짓으로 표시한다. 진행 상태가 queued와 running인 경우에만 다음 읽기를 기다린다.

기다림에도 끝나는 기준을 명시했다

monotonic 시계로 deadline을 정하고 읽기 횟수에도 한도를 둔다. 날짜가 바뀌거나 시스템 시각을 조정한 경우와 실행 경과 시간을 섞지 않기 위한 선택이다. 시간과 간격은 유한한 양수만 받고 참값이나 NaN을 거절한다. 남은 시간보다 긴 sleep을 예약하지 않는다.

다만 이 루프는 콜백 자체가 영원히 반환하지 않는 상황을 강제로 끊지 못한다. 실제 HTTP나 파이프 어댑터에는 별도 전송 제한 시간이 필요하다. 이 대기에서 늦게 도착한 성공은 시간 안의 성공으로 세지 않지만, 원격 작업이 실제로 끝났을 가능성은 남는다. 그래서 다음 행동은 재전송보다 원래 ID의 결과 확인이어야 한다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Wait for one existing run; no submission/start/retry operation exists here."""
import asyncio,time,math

class WaitError(RuntimeError):
 def __init__(self,code,run_id,polls):
  self.code=code;self.run_id=run_id;self.polls=polls;self.automatic_replay=False;super().__init__(code)

async def wait_existing(read_status,*,run_id,scope,timeout=30,interval=.1,max_polls=1000,stopped=lambda:False,clock=time.monotonic,sleep=asyncio.sleep):
 if not isinstance(run_id,str) or not run_id or not isinstance(scope,dict) or set(scope)!= {'profile','project'} or any(not isinstance(v,str) or not v for v in scope.values()):raise ValueError('INVALID_WAIT_CONTEXT')
 if isinstance(timeout,bool) or not isinstance(timeout,(float,int)) or not math.isfinite(timeout) or not 0<timeout<=300:raise ValueError('INVALID_TIMEOUT')
 if isinstance(interval,bool) or not isinstance(interval,(float,int)) or not math.isfinite(interval) or not 0<interval<=10:raise ValueError('INVALID_INTERVAL')
 if type(max_polls) is not int or not 1<=max_polls<=10000:raise ValueError('INVALID_POLL_LIMIT')
 deadline=clock()+timeout;expected=dict(scope);polls=0
 while True:
  if stopped():raise WaitError('WAIT_STOPPED',run_id,polls)
  if clock()>=deadline:raise WaitError('WAIT_TIMEOUT',run_id,polls)
  if polls>=max_polls:raise WaitError('WAIT_POLL_LIMIT',run_id,polls)
  polls+=1
  try:row=await read_status(run_id)
  except Exception as e:raise WaitError('STATUS_READ_FAILED',run_id,polls) from e
  if stopped():raise WaitError('WAIT_STOPPED',run_id,polls)
  if clock()>=deadline:raise WaitError('WAIT_TIMEOUT',run_id,polls)
  if not isinstance(row,dict) or set(row)!={'id','scope','status','verified'} or type(row['verified']) is not bool:raise WaitError('INVALID_RECEIPT',run_id,polls)
  if row['id']!=run_id or row['scope']!=expected:raise WaitError('RECEIPT_SCOPE_CHANGED',run_id,polls)
  status=row['status']
  if status=='succeeded':
   if not row['verified']:raise WaitError('RESULT_NOT_VERIFIED',run_id,polls)
   return {'run_id':run_id,'status':'succeeded','verified':True,'polls':polls,'automatic_replay':False}
  if status in ['failed','cancelled','needs_review']:raise WaitError('RUN_'+status.upper(),run_id,polls)
  if status not in ['queued','running']:raise WaitError('INVALID_RUN_STATUS',run_id,polls)
  await sleep(min(interval,max(0,deadline-clock())))

중지가 성공보다 먼저 반영되는 지점

읽기 전뿐 아니라 await로 상태를 받은 뒤에도 stopped를 확인한다. 사용자가 기다리는 중에 멈췄는데 늦은 succeeded 응답이 왔다고 정상 완료를 표시하지 않도록 했다. 이 모듈의 중지는 로컬 기다림을 멈추는 뜻이며 서버에서 이미 처리한 변경을 취소하지 않는다.

상태 읽기가 예외를 내면 STATUS_READ_FAILED를 돌려주고 같은 읽기를 무한히 반복하지 않는다. 연결 오류는 작업이 안 보내졌다는 증거가 아니다. 구조화된 예외에는 원래 run_id와 읽기 횟수가 남는다. 이후 운영자가 어느 실행을 다시 확인해야 하는지 알 수 있지만, 이 예제에 지속 저장이나 재시작 복구 기능은 없다.

18개 시험과 다섯 기록을 따로 남겼다

시험은 대기→실행→검증 성공, 검증되지 않은 성공, 시간과 횟수 초과를 확인한다. 중지 전후, 다른 실행 ID와 프로젝트, 실패·취소·확인 필요, 알 수 없는 상태와 필드도 검사한다. 상태 읽기 실패와 await 중 중지, deadline 뒤 돌아온 성공을 별도 경우로 다뤘다.

18개 시험을 소스와 실제 ZIP 추출 폴더에서 실행했다. 예제 기록의 submission_operations는 코드에 시작 기능이 없다는 범위에서 0이다. 이것을 원래 북극성의 모든 실행 기록이나 외부 대화 토큰의 측정값으로 바꾸지 않는다. 아래 선택기는 다섯 합성 실행 기록을 읽어 보여 줄 뿐 원격 작업을 기다리지 않는다.

합성 로컬 기록의 읽기 뷰어

실제 운영에 연결할 때 추가할 책임

다운로드에는 전체 구현과 시험, 기록 생성기, README가 들어 있다. 수정·재사용할 수 있지만 신뢰할 인증 문맥과 실제 읽기 전용 어댑터, 전송 제한 시간, 영속 영수증 보관은 사용하는 쪽에서 연결해야 한다. 여러 프로세스가 같은 업무를 시작하지 않게 하는 잠금도 별도로 필요하다.

나는 작은 대기 함수 하나로 정확히 한 번의 외부 처리를 보장한다고 쓰지 않는다. 이 모듈이 주는 것은 이미 시작한 실행을 기다리며 새 제출을 만들지 않는 명확한 책임이다. 로컬 대기를 종료한 뒤에도 기존 실행의 결과를 확인할 길을 남겨야 한다. 편한 자동화는 다음 클릭을 더 만드는 것보다 어떤 클릭을 다시 만들지 않을지 정하는 데서 시작할 수 있다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능