AI 코딩 에이전트에게 작업 시도(Work Attempts), 임대(Leases), 체크포인트(Checkpoints)가 필요한 이유
요약
AI 코딩 에이전트의 협업 및 실행 과정에서 발생하는 조정(Coordination) 실패 문제를 해결하기 위한 데이터 모델을 제안합니다. 작업 시도(Work Attempts), 임대(Leases), 체크포인트(Checkpoints) 개념을 도입하여 에이전트의 충돌, 망각, 충돌 문제를 방지하는 방법을 다룹니다.
핵심 포인트
- 에이전트의 실패 모드: 프로세스 충돌, 컨텍스트 망각, 작업 중복 충돌
- 분산 시스템의 원리를 적용한 에이전트 모델링 필요성
- 임대(Lease)와 하트비트 개념을 통한 작업 자동 회수 메커니즘
- 인간 중심의 트래커 모델과 에이전트 중심 모델의 차이점
우리는 AI 에이전트에게 코드를 작성하고, 테스트를 실행하며, 풀 리퀘스트(Pull Request)를 생성하고, 서로에게 작업을 전달할 수 있는 능력을 부여했습니다. 그 후 우리는 TODO.md 파일과 공유 채팅창을 통해 이들이 협업하도록 요청했습니다.
그러한 불일치는 모델의 지능과는 전혀 상관없는 새로운 범주의 버그들을 발생시킵니다.
한 에이전트가 작업을 맡아 절반쯤 진행했을 때, 컨텍스트 윈도우(Context Window)가 가득 찹니다. 다른 터미널에서 실행 중인 또 다른 에이전트는 해당 작업이 이미 진행 중이라는 알림을 받지 못했기 때문에 동일한 작업을 가로챕니다. 세 번째 에이전트는 편집 도중 충돌(Crash)이 발생하여 작업을 영원히 "진행 중" 상태로 남겨둡니다. 아무도 지울 수 없는 묘비가 되는 것입니다.
이 중 그 어느 것도 추론(Reasoning)의 실패가 아닙니다. 이것들은 **조정(Coordination)**의 실패이며, 두 개 이상의 에이전트를 실행하거나 하나의 에이전트를 단일 대화보다 더 오래 실행하는 순간 나타납니다.
이 글은 이러한 문제들을 해결하는 데이터 모델에 관한 것입니다. 이 모델은 제가 이 문제를 해결하기 위해 구축한 오픈 소스 MCP 서버인 rhizome-mcp에서 가져왔지만, 그 아이디어는 어디든 적용 가능합니다. **작업 시도(Work attempts), 임대(Leases), 그리고 체크포인트(Checkpoints)**는 이를 설치하든 안 하든 유용합니다.
에이전트는 신뢰할 수 없는 작업자입니다. 그런 방식으로 모델링하세요.
분산 시스템(Distributed systems)은 수십 년 전에 이미 이 문제를 해결했습니다. 작업자가 시작한 일을 반드시 끝낼 것이라고 가정하지 마십시오. 작업자는 죽습니다. 네트워크는 분할(Partition)됩니다. 프로세스는 OOM-kill(Out of Memory kill)을 당합니다. 그럼에도 불구하고 큐(Queue)는 반드시 정확한 상태를 유지해야 합니다.
AI 코딩 에이전트는 세 가지 반복적인 실패 모드를 가진 신뢰할 수 없는 작업자입니다:
- 충돌합니다(They crash). 프로세스가 종료되거나, 터미널이 닫히거나, 노트북이 잠들거나, API 호출이 시간 초과됩니다. 에이전트가 처리하던 모든 것은 버려집니다.
- 잊어버립니다(They forget). 컨텍스트 창은 유한합니다. 긴 작업은 결국 자체 시작 지침을 창 밖으로 밀어내고, 에이전트는 진행 도중 실마리를 놓칩니다. 이는 컨텍스트-창 기반 에이전트에게서 관찰되는 매우 빈번하고 본질적인 실패 모드입니다.
- 충돌합니다(They collide). 두 개의 에이전트—또는 하나의 에이전트를 실행한 두 번의 시도, 또는 서로 다른 제품의 에이전트들—가 같은 저장소에서 작업하며 이미 진행 중인 작업을 공유하는 정보가 없기 때문에 동일한 다음 작업을 선택합니다.
전통적인 트래커 모델(Jira, Linear, GitHub Issues)은 인간 팀을 위해 구축되었으며, 여기서 조용히 깨지는 두 가지 가정을 내포하고 있습니다.
영구 할당자(Permanent assignee). 티켓을 사람에게 할당하면 재할당될 때까지 그 사람이 소유합니다. 에이전트는 영구적인 정체성을 갖지 못합니다. 그것은 하나의 연결을 위해 존재했다가 사라지는 프로세스일 뿐입니다.
임대(Lease)는 만료됩니다. 시도(Attempt)는 lease_expires_at 시점까지만 유효합니다. 계속해서 작업을 수행하기 위해, 에이전트는 하트비트(heartbeat) 역할을 하는 renew_attempt를 호출합니다. 에이전트가 충돌(crash)하거나, 멈추거나(hang), 강제 종료(killed)되어 하트비트를 중단하면 임대는 소멸됩니다. 죽은 작업자(worker)로부터 별도의 정리(cleanup) 작업이 필요하지 않습니다. 서버는 경과된 시간만으로 해당 시도를 회수할 수 있습니다.
토큰은 정체성(identity)이 아니라 권한(capability)입니다. 가공되지 않은 lease_token은 정확히 한 번 반환되며, 서버 측에는 오직 그 해시(hash)값만 저장됩니다. 해당 시도에 대한 모든 변경(mutating) 호출은 반드시 attempt_id + lease_token을 제시해야 합니다. 토큰을 보유한 사람이 해당 시도를 소유합니다. 로그인도, 등록도 필요하지 않습니다. 토큰을 유지하고 있다면 완전히 새로운 세션도 시도를 재개할 수 있으며, 이것이 바로 핸드오프(handoff)에 필요한 기능입니다. 또한 서버는 호출자가 누구인지 확인할 필요가 없습니다.
소유권은 관례(convention)가 아니라 데이터베이스에 의해 강제됩니다. 부분 유니크 인덱스(partial unique index)를 통해 하나의 이슈(issue)당 최대 하나의 활성 시도(active attempt) 행만 허용할 수 있습니다:
CREATE UNIQUE INDEX idx_one_active_attempt_per_issue
ON work_attempts(issue_id)
WHERE status = 'active';
두 에이전트가 동일한 이슈를 차지하기 위해 경쟁할 경우: 하나는 INSERT에 성공하지만, 다른 하나는 제약 조건 위반(constraint violation)에 걸려 해당 이슈가 이미 선점되었음을 통보받습니다. 이 불변성(invariant)은 TOCTOU(Time-of-Check to Time-of-Use) 간극이 발생하는 애플리케이션 레벨의 체크에 의존하지 않습니다. 테이블 자체가 하나의 이슈에 대해 두 개의 활성 시도를 가질 수 없도록 보장하기 때문입니다.
여기서 한 가지 미묘한 점이 있습니다. 행의 상태가 여전히 status = 'active'로 표시되어 있더라도, 실제 시간(wall-clock time) 기준으로 임대는 만료될 수 있습니다. 새로운 시도를 삽입하기 전에, 점유 트랜잭션(claim transaction)은 만료된 모든 활성 시도를 expired 상태로 구체화(materialize)합니다. 그러면 부분 유니크 인덱스가 남은 경쟁 구간(race window)을 보호합니다. 만료 처리 후에는 단 하나의 동시 삽입(concurrent insert)만이 성공할 수 있습니다.
시도 생명주기 (The attempt lifecycle)
[*] -- claim_issue ------------------------> [active]
[active] -- renew_attempt (heartbeat) ----------> [active]
[active] -- finish_attempt (done / review) -----> [completed] --> [*]
...
expired는 에이전트가 아무것도 하지 않았을 때 도달하는 유일한 최종 상태(terminal state)입니다. 그것이 바로 핵심입니다.
핵심 통찰: in_progress는 상태가 아니다
이것이 모든 것의 기반이 되는 결정입니다:
in_progress는 저장된 이슈 상태가 아닙니다. 계산됩니다.
이슈의 저장된(stored) 상태는 open, ready, blocked, review, done, cancelled 중 하나입니다. 해당 열에는 in_progress 값이 없습니다. 서버는 읽기 시점에 **유효 상태(effective status)**를 파생합니다:
Issue stored status: ready
|
+-- no active leased attempt
...
가장 아래 가지를 다시 읽어보세요. 이것이 전체 논거입니다. 임대 기간이 만료되면, 이슈를 _재설정(reset)_할 필요가 없습니다. 저장된 상태는 처음부터 계속 ready였고; 단지 파생된 뷰만 변경되었으며, 그것은 스스로 원래대로 돌아옵니다.
이 모델 하에서는, 충돌한 워커가 영구적인 in_progress 상태를 남길 수 없습니다. 이 막힌 티켓(stuck-ticket) 문제는 별도의 상태 재설정 워크플로우로 수정되는 것이 아니라 데이터 모델에 의해 제거됩니다.
저장된 상태 접근 방식과 비교해 봅시다. 여기서는 충돌이 발생하면 실제 in_progress 값이 남고, 무언가가 그것을 인지하고 재설정해야 합니다. 바로 그
만료(Expiry)는 이슈에 저장된 상태를 절대 재작성하지 않습니다. 단지 활성화된 잠금(active lock)을 해제할 뿐입니다. 해당 이슈가 다시 작업 가능해질지는 시도 자체가 없었던 것처럼, 오직 자체 저장 상태와 블로커(blockers)에 의해 결정됩니다.
만료된 시도는 재개될 수 없습니다. 그 임대 기간은 끝났기 때문입니다. 새로운 클레임은 새로운 시도를 시작하며, 체크포인트 덕분에 제로(zero)부터 다시 시작하지 않습니다.
체크포인트: '재개'가 곧 '재시작'을 의미하지 않도록
슬롯(slot)을 복구하는 것이 문제의 절반입니다. 나머지 절반은 _작업_을 복구하는 것입니다.
40분을 사용하고 컨텍스트 창(context window)의 절반을 사용하여 코드베이스를 탐색한 에이전트는 값비싼 상태(expensive state)를 구축했습니다. 만약 다음 시도가 이 모든 것을 재발견한다면, 같은 작업을 두 번 비용 지불하게 됩니다.
시도하는 동안 에이전트는 네 가지 종류의 **시도 노트(attempt notes)**를 작성합니다:
progress: 일반적인 상태 업데이트finding: 주목할 만한 기술적 발견warning: 플래그 지정할 가치가 있는 위험 또는 문제checkpoint: 재시작 가능한 요약(restartable summary)
체크포인트가 중요합니다. 이는 에이전트가 자신의 후임자에게 보내는 브리핑서와 같습니다.
// save_attempt_note
{
"kind": "checkpoint",
...
나중에 어떤 에이전트가 해당 이슈에 대한 컨텍스트를 요청하더라도, 서버는 전체 노트 기록보다 가장 최근 체크포인트를 선호하는 제한된 패키지를 조립하고, 여기에 이전 시도의 결과 요약과 다음 단계를 추가합니다. 새로운 세션(심지어 다른 에이전트 제품일 수도 있음)은 차가운 읽기(cold read) 대신 간결한 브리핑서로부터 시작합니다.
이는 또한 의도적인 인수인계(handoffs)를 일급 시민(first-class)으로 만듭니다. outcome: interrupted와 interruption_reason_code: context_limit로 끝나는 에이전트는 실패하는 것이 아닙니다. 깔끔하게 작업을 중단하고 다음 실행자가 발견할 수 있도록 바통을 넘기는 것입니다.
엔드투엔드로 볼 때의 모습
14:00 — Agent A가 ISSUE-42를 클레임합니다.
14:05 — Agent A가 리포지토리 분석을 완료한 후 체크포인트를 저장합니다.
14:08 — Agent A가 충돌하여 임대 갱신을 중단합니다.
...
해당 이슈는 수동으로 재할당된 적이 없으며, 오래된 `in_progress` 상태를 저장한 적도 없습니다. 첫 번째 시도는 과거 실행 데이터가 되었고, 두 번째 시도는 마지막 유효한 체크포인트(Checkpoint)부터 계속되었습니다.
## 토큰 효율적인 컨텍스트 (Token-efficient context)
전통적인 API는 관대한 JSON 응답을 반환할 여유가 있습니다. 하지만 에이전트 대상 API(Agent-facing APIs)는 그렇지 않은 경우가 많습니다. 불필요한 모든 필드는 토큰을 소비하고, 비용을 발생시키며, 모델의 작업 컨텍스트 (Working context)와 경쟁합니다. 지나치게 비대해진 응답은 트래커가 방지하려는 것과 동일한 실패 모드, 즉 에이전트가 맥락을 놓치는 현상(Loses the thread)에 직접적인 원인이 될 수 있습니다.
따라서 도구 계약(Tool contract)은 희망 사항에 의존하기보다, 강제되고 테스트되는 예산(Budgets) 내에서 _여전히 충분하면서도 최소한의 데이터_를 반환하도록 설계되었습니다.
- **압축된 리스트 프로젝션 (Compact list projections).** `list_issues`는 식별자(Identifiers), 제목(Title), 분류(Classification), 그리고 계산된 상태(Computed status) 필드를 반환하지만, 자유 형식의 텍스트인 `description`과 `acceptance_criteria` 본문은 **반환하지 않습니다**. 프로젝트 자체 테스트 피스처(Test fixture)에서 100개의 이슈가 포함된 페이지를 측정했을 때, 전체 데이터는 **≈582 KB**인 반면 압축된 데이터는 **≈46 KB**였습니다. 이는 본문 포함 여부에 따라 동일한 이슈에 대해 약 12배의 차이가 발생함을 보여줍니다. 통합 테스트를 통해 본문의 크기와 관계없이 압축된 응답이 64 KB 미만으로 유지됨을 보장합니다.
- **SQL 레이어에서 자유 텍스트를 제외하는 그래프 (Graphs exclude free text at the SQL layer).** 그래프 쿼리는 `NULL AS description, NULL AS acceptance_criteria`를 선택하여, 탐색(Traversal)을 수행하기 _전_에 크기가 제한되지 않은 필드들을 제거합니다. 응답 크기는 이슈 본문의 상세함이 아니라, 사용자가 제어할 수 있는 `max_nodes` 제한에 따라 확장됩니다.
- **문서가 아닌 스니펫을 반환하는 검색 (Search returns snippets, never documents).** 전체 텍스트 검색 (SQLite FTS5)은 SQL에서 잘려진(Truncated), 검색 결과당 제한된 스니펫(Snippet)을 반환합니다. 사용자는 포인터와 미리보기를 받은 다음, 실제로 필요한 단 하나를 가져옵니다.
- **상태 재읽기 대신 델타 동기화 (Delta sync instead of re-reading state).** 모든 변경(Mutation)은 단조 증가 식별자(Monotonic ID)를 가진 이벤트 로그에 추가됩니다. `get_changes(since_event_id)`는 마지막으로 확인한 이후에 발생한 사항만을 반환합니다.
- **하나의 경계 컨텍스트 패키지 (One bounded context package).** `get_work_context`는 이슈 본문(issue body), 해결되지 않은 차단 요소(unresolved blockers), 활성 결정 요약(active decision summaries), 최신 체크포인트(latest checkpoint), 이전 시도의 다음 단계(previous attempt's next steps)를 포함하는 단일 큐레이션 번들(curated bundle)을 반환합니다.
이 교훈은 이 서버 하나를 넘어 일반화됩니다. **API 소비자(consumer)가 LLM일 때, 응답 형태(response shape)는 성능 예산(performance budget)의 일부입니다.** 기본적으로 본문(bodies)은 생략하고, 비용이 많이 드는 필드는 선택 사항(opt-in)으로 만들며, 델타 피드(delta feed)를 전송하십시오.
## SQLite가 이 워크로드에 적합한 이유
로컬 SQLite 데이터베이스는 네트워크 데이터베이스에 비해 소박하게 들릴 수 있지만, 이 워크로드에는 이례적으로 잘 맞습니다.
이 워크로드는 한 대의 머신에서 실행되는 한 개발자의 에이전트 함대(fleet of agents)입니다. 멀티 노드(multi-node) 요구 사항도, 교차 지역 복제(cross-region replication)도, 수천 개의 동시 쓰기 작업자(concurrent writers)도 필요하지 않습니다. 대신 요구되는 것은 **원자적이고 충돌 안전한(atomic, crash-safe) 다중 테이블 쓰기**입니다. 이슈를 처리하려면 단 하나의 불가분한 단계(indivisible step) 내에서 시도(attempt)를 생성하고, 이슈 버전과 시작 시점의 이벤트 위치를 기록하며, `attempt_started` 이벤트를 추가해야 합니다. 여기서 부분적인 쓰기(partial write)가 발생하면 조정 모델(coordination model)이 손상됩니다.
SQLite는 별도의 데이터베이스 서비스 없이도 쓰기 작업자를 직렬화(serialize)하고 이 조정 모델에 필요한 트랜잭션 보장(transactional guarantees)을 제공합니다.
PRAGMA journal_mode = WAL; -- 읽기 작업자가 쓰기 작업자를 차단하지 않음
PRAGMA busy_timeout = 5000; -- 에러를 내는 대신 잠금(lock)을 기다림
PRAGMA foreign_keys = ON;
...
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기