본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 03. 19:49

한 번의 감사 사이클을 다섯 번 재실행하여 발견한 일곱 가지의 서로 다른 버그

요약

동일한 감사 프롬프트를 동일한 엔진 상태에 대해 반복 실행하여 발견한 7가지 독립적인 버그 사례를 다룹니다. 반복적인 감사 사이클이 이전 실행의 수정 사항을 새로운 컨텍스트로 삼아 상태의 미세한 결함을 찾아내는 과정을 설명합니다.

핵심 포인트

  • 동일 프롬프트의 반복 실행이 새로운 컨텍스트를 생성하여 버그 발견율을 높임
  • 각 버그는 단일 근본 원인이 아닌 독립적인 루프 결함임
  • 상태 저장 실패와 타임아웃 설정 오류가 결합된 복합적 버그 사례 분석

우리는 자체 코드베이스에 대해 매 3시간 44분마다 반복적인 감사 (Audit) 사이클을 실행합니다. 2026-06-03, 04:08Z에서 06:15Z 사이에 동일한 사이클이 연속으로 다섯 번 실행되었습니다. 두 번은 하네스 (Harness)가 재트리거(retrigger)했기 때문이고, 나머지 세 번은 우리가 계속 허용했기 때문입니다.

각 실행은 서로 다른 서브시스템 (Subsystem)에서 서로 다른 버그를 찾아냈습니다. 일곱 가지 버그 중 어느 것도 하나의 근본 원인 (Root cause)에서 파생된 형제 인스턴스가 아니었습니다. 그것들은 다른 어떤 것도 살펴보고 있지 않았던, 독립적으로 고장 난 루프 (Loops)들이었습니다.

이 포스트는 그 일곱 가지를 기록합니다. 핵심은 "우리가 패치를 배포했다"가 아닙니다. 패치는 당연히 일어나는 일입니다. 핵심은 동일한 감사 프롬프트 (Audit prompt)를 동일한 엔진 상태 (Engine state)에 두 시간 동안 다섯 번이나 다시 겨냥했을 때 어떤 일이 발생하는가 하는 점입니다.

감사 패턴 (The audit pattern)

사이클 프롬프트는 대략 다음과 같습니다: "여기에 엔진의 JSON 특성 정의 (Characterization)가 있습니다 — F-score, 루프 상태 (Loop state), 이상 징징 리스트 (Anomaly list), 최근 커밋 (Recent commits), 채널 침묵 플래그 (Channel-silence flags). 이번 사이클에서 조치가 필요한 사항이 있는지(있다면 무엇인지) 결정하십시오. 실행하십시오. 판결을 작성하십시오."

특성 정의는 매 실행 시마다 새롭게 재생성됩니다. 따라서 동일한 사이클이 두 번째로 실행될 때, 특성 정의는 첫 번째 실행이 방금 생성한 상태를 포착합니다. 감사는 가장 최근의 디스크 (Disk)를 대상으로 합니다.

우리 중 누구도 예측하지 못한 것은, 새로운 디스크를 다시 겨냥하는 것 자체가 감사라는 점이었습니다. 각 실행은 이전 실행의 수정 사항을 새로운 컨텍스트 (Context)로 삼아 상태의 약간씩 다른 조각을 보게 되며, 즉시 다음의 느슨한 실타래를 잡아당기기 시작합니다.

버그 #1 — 큐레이터 (The Curator)는 자신이 결정하는 것을 본 적이 없다

기본 실행 (04:08Z). 바이옴 합의 루프 (Biome consensus loop)에는 패치를 제안하고 서로의 제안에 투표하는 세 개의 봇(Bot) — 큐레이터 (Curator), 스프린터 (Sprinter), 아키텍트 (Architect) — 가 있습니다. 상태 파일 (State file)에는 last_run_at: 2026-05-27T04:38:49Z라고 되어 있습니다. 7일 동안 얼어붙어 있었습니다.

근본 원인은 네 가지 계층의 복합적인 문제였습니다:

  • 각 봇의 proposePR() 함수는 피어(peer)로부터 두 개의 YES 투표를 받기 위해 인박스(inbox)를 폴링(polling)하는 과정에서 waitForConsensus()에 의해 60초 동안 차단(block)되었습니다.
  • 동일한 유휴 라운드(idle round) 내에서 피어들이 아직 실행되지 않은 상태였습니다. 따라서 제안자(proposer)는 항상 1-of-1(자신의 투표)만 확인하고 타임아웃(timeout)이 발생했습니다.
  • 60초의 차단 시간은 90초의 실행 타임아웃(exec timeout)을 가진 유휴 브리지 옵저버(idle-bridge observer) 슬롯 내에 위치했습니다. 봇은 saveState()가 실행되기도 전에 폴링 도중 SIGTERM을 받았습니다.
  • 상태(state)가 저장되지 않으면서 canBotOpenPR 스로틀(throttle)이 작동하지 않았고, 결과적으로 모든 유휴 라운드에서 실패할 것이 뻔한 시도에 약 90초를 낭비했습니다.

조사 시점의 인박스 상태: 1752개의 오래된 대기 중인 제안(stale-pending proposals). 그 중 어느 것도 두 명의 고유 투표자(two-unique-voter)에 의한 YES 정족수(quorum)에 도달하지 못했습니다.

해결책: waitForConsensus 타임아웃을 60초에서 10초로 단축했습니다. 타임아웃 발생 시 상태를 pending으로 남겨두고 별도의 PR-오프너(PR-opener) 에이전트에게 넘기도록 했습니다 (이 에이전트는 이전 은퇴 사이클(retire-cycle)에서 9일 전에 생성되었으나, 옵저버 리스트(observer list)에 연결되지 않은 상태였습니다). PR-오프너를 옵저버 리스트에 추가했습니다. 총 3개의 파일을 패치했습니다.

버그 #2 — PR-오프너가 Architect의 투표를 두 번 계산함

동일한 화재 상황입니다. PR-오프너를 연결하는 과정에서, 이것이 일주일 된 제안을 dry_run_would_open 후보로 노출하고 있음을 발견했습니다. 집계(tally) 함수는 중복을 포함하여 votes.filter(v => v.vote === "YES").length를 계산하고 있었습니다. Architect가 동일한 제안에 대해 YES라고 두 번 투표하여, 이를 2-YES 정족수로 노출시킨 것입니다.

다른 합의 소비자(consensus consumer)인 bot_consensus.mjs:tallyProposal은 투표자별로 중복을 제거(deduped)했습니다. 하지만 PR-오프너는 그렇지 않았습니다. 이는 #1과는 별개의 독립적인 버그였으며, 단지 우리가 우연히 해당 파일을 살펴보게 된 것이었습니다.

해결책: 투표자 기준 중복 제거 Map을 적용했습니다. 후보가 1개에서 0개로 감소했습니다 (정상 — 아직 실제 합의가 형성되지 않았음).

버그 #3 — Architect가 항상 예산 윈도우(Budget Window)의 마지막에 위치함

재발 (04:42Z, 약 34분 후). 패치 #1과 #2는 유지되었습니다: curator와 sprinter의 상태 파일은 최신 상태였습니다. 하지만 Architect의 상태 파일은 여전히 28시간 동안 정체되어 있었습니다.

canBotOpenPR 결정 로그: 모두 allowed:true reason:ok였습니다. 스로틀(throttled)되지 않았습니다. 하지만 상태(state) 필드는 전진하지 않았습니다.

예산 폭포(budget cascade)를 추적했습니다: architect는 120초 타임아웃(timeout)을 가진 observer 슬롯 14에 있었습니다. 부하가 많은 라운드에서 슬롯 14 이전까지의 누적 실제 경과 시간은 약 146초였습니다 (curator 56초, sprinter 56초, 8개의 저렴한 observer 합계 30-40초). RUN_BUDGET_MS = 240s. 146 + 120 = 266 > 240. 매 라운드마다 skip-budget가 발생합니다.

해결책: architect를 슬롯 9(curator 이전)로 이동합니다. 트레이드오프(Trade-off): 부하가 많은 라운드에서 더 가벼운 observer가 대신 skip-budget를 수행할 수 있습니다. 1752개의 stale-pending 제안서(proposals)에 대해 architect의 투표가 정족수 병목(quorum bottleneck)이었으므로 이는 수용 가능한 수준입니다.

버그 #4 — Architect의 저장 경로가 느린 호출(Slow Call) 이후에 위치함

3차 발생 (05:08Z). 예산 패치(budget patch)로 스케줄링은 해결되었지만, architect의 상태(state) 필드는 여전히 전진하지 않았습니다. 하지만 합의 로그(consensus log)에는 28분 전의 투표 하나를 포함하여 architect에 의한 623개의 투표가 기록되어 있었습니다. 그리고 인박스(inbox)에는 가장 최근인 30분 전의 것을 포함하여 architect에 의한 297개의 제안서가 표시되어 있었습니다.

즉, architect는 실행되고 있었습니다. 단지 저장(saving)을 하지 못했을 뿐입니다.

main()을 읽어보니: proposal_posted 로그 라인은 580번 라인에 있었습니다. 첫 번째 saveState() 호출은 593번 라인에 있었는데, 이는 10-30초가 걸릴 수 있는 waitForConsensus() 호출 이후였습니다. 그 전의 번들 인지(Bundle cognition) 과정은 번들 크기(bundle_size)에 따라 추가로 30-60초 × 번들 크기만큼 걸릴 수 있었습니다. idle_bridge 실행 타임아웃(exec timeout)은 593번 라인에 도달하기도 전에 매번 프로세스에 SIGTERM을 보냈습니다.

해결책: 느린 waitForConsensus() 호출 전, proposal_posted 로그 직후에 방어적인 saveState()를 배치합니다. 단 8줄의 코드입니다. 이제 스로틀(throttle) 관련 필드가 느린 경로를 지난 첫 번째 안전한 지점에서 영속화(persist)됩니다.

Curator와 sprinter는 인지(cognition) 속도가 약 10배 더 빠르고 타임아웃 내에 완료되기 때문에 이 문제에 걸리지 않았습니다. 형태는 같지만, 영향 범위(blast radius)가 달랐던 것입니다.

버그 #5 — 사실과 다른 이유로 Focus Override가 5일 동안 동결됨

4차 발생 (05:50Z). 완전히 다른 서브시스템(subsystem)입니다. meta/focus_override.jsonself_test_failed_fix_before_new_features라는 이유와 함께 priority=100, active=true, ts=2026-05-29T03:07Z가 설정되어 있었습니다. 122.6시간 동안 동결된 상태였습니다.

하지만 meta/self_test.json은 오버라이드(override)가 기록되기 사흘 인 2026-05-26T17:20Z에 overall: pass, failed_count: 0을 보여주고 있었습니다. 오버라이드의 사유는 작성된 당일 기준으로 사실과 달랐습니다.

소비자(consumer, round_director.mjs)는 active:false로 전환하고 파일 이름을 .consumed.json으로 변경하는 유일한 코드였습니다. Grep 결과 2026-05-21 이후로 성공적인 소비(consume)는 0건이었습니다. Round-director는 13일 동안 DAG에서 소리 없이 사라져 있었습니다.

한편, 작성 가드(writer guard)는 existing.priority (100) >= incoming.priority라는 이유로 들어오는 모든 높은 우선순위의 신호를 거부했습니다. 지난 3시간 동안 wiring_smoke_red_critical_agent_stale을 포함하여 6개의 고유한 실제 신호가 거부되었습니다.

전형적인 일회성 파일 쐐기(one-shot-file wedge) 현상입니다: 소비자가 죽으면, 작성자의 우선순위 규칙이 영원히 거부하게 됩니다.

해결책: 기존 항목에 대해 6시간의 신선도 TTL(Time To Live)을 적용했습니다. 6시간이 지나면 기존 항목을 비활성(inactive) 상태로 취급하여 새로운 쓰기가 승리(WIN)하도록 합니다. 이전의 사유/우선순위/경과 시간을 포함한 replaced_stale 로그 라인을 생성합니다. 5일 된 파일을 삭제하여 다음 신호가 TTL을 기다리는 대신 즉시 승리할 수 있도록 했습니다.

버그 #6 — PowerShell이 셀프 힐(Self-Heal) 스크립트를 파싱할 수 없음

5번째 화재(06:15Z). meta/idle_bridge_state.json.last_maintenance_at = 2026-05-25T10:40:18Z. 9일 동안 동결되었습니다. 모든 idle-bridge 라운드에서 maintenance:fail이 기록되었습니다.

직접 호출 결과: 196행 82자를 기점으로 하는 9개의 ParserError 라인이 발생했습니다.

$state.Add("  self-heal: vercel deploy FAILED �?" $tail")

엠 대시(em-dash) (UTF-8 바이트 E2 80 94)가 �?로 렌더링되었는데, 이는 Windows의 PowerShell 5.x가 BOM(Byte Order Mark)이 없는 .ps1 파일을 UTF-8이 아닌 ANSI/Windows-1252로 읽기 때문입니다. 문자열 중간의 쓰레기 바이트(garbage bytes)가 문자열 리터럴을 조기에 종료시켜 나머지 라인을 파싱할 수 없게 만들었습니다.

파일 감사 결과: 총 28개의 비 ASCII 문자가 발견되었습니다. 문자열 리터럴 내부의 두 개의 엠 대시(196행 및 201행)만이 파서를 망가뜨렸습니다. 주석 내의 엠 대시는 파서가 허용했습니다. 주석 라인의 쓰레기 바이트는 여전히 라인 종료를 정상적으로 수행하기 때문입니다.

수정 사항: 문자열 리터럴(string literals) 내부의 두 개의 엠 대시(em-dashes)를 --로 교체했습니다. 두 글자 수정입니다. 파서(Parser) 자체 테스트 결과가 9개의 오류에서 0개로 줄었습니다.

부작용: 해당 스크립트 내의 vercel 자가 치유(self-heal) 블록(드리프트(drift) 감지 시 vercel --prod를 호출하는 부분)이 지난 9일 동안 조용히 작동하지 않고 있었습니다. 이는 정확히 그러한 상황을 방어하기 위해 사이클 44에서 작성했던 교리(doctrine)를 무력화시킨 것이었습니다.

버그 #7 — 셀프 테스트 필드가 거짓을 말하고 있었다

이 포스트를 작성하던 중 5번째 실행(5th-fire) 이후에 태그되었습니다. meta/self_test.json은 2026-05-26T17:20Z에 overall: pass를 보여줍니다. 하지만 focus_override는 오늘 06:46Z라는 최근 시점에도 감사(audit) 윈도우 내에서 self_test_failed_fix_before_new_features로 계속 다시 작성되고 있습니다.

어떤 프로듀서(producer)가 잘못된 셀프 테스트 실패를 단언(asserting)하고 있습니다. 버그 #5에서 발생한 6시간의 TTL(Time To Live)이 이를 자동으로 해제하겠지만, 해당 프로듀서가 누구인지는 식별되지 않았습니다. 이것이 바로 5번째 실행의 패치들이 우리가 발견할 수 있도록 가능하게 해준 버그입니다. TTL이 없었다면, 이 잘못된 쓰기(false-write)는 기존의 쐐기(wedge)를 확인해 주는 것에 그쳤을 것이고, 우리는 이를 정상 상태(steady-state)로 읽었을 것입니다.

다음 사이클을 위해 목록화했습니다. 이것이 감사 루프(audit loop)가 다음에 가져올 스레드입니다.

이번 실행이 배제하는 것

이를

이것들은 모두 하나의 버그, 즉 **코드가 읽고 있다고 생각하는 세계와 일치하지 않는 상태 필드 (state field)**의 변형들입니다. 이들 각각은 작성할 당시에는 모두 멀쩡해 보입니다. 하지만 소비자/생산자 (consumer/producer) 체인이 변화하면, 이들은 모두 조용히 부패합니다.

해결책은 더 많은 경계심이 아닙니다. 작성자가 소비자가 살아있는지에 대해 책임을 지도록 만드는 것입니다. 우리는 아직 이를 구현할 깔끔한 형태를 가지고 있지 않습니다. 우리가 가진 가장 유사한 원시 기능 (primitive)은 버그 #5에서 추가한 TTL입니다. 아무도 읽지 않는다면 필드가 스스로 삭제되도록 하는 방식이죠. 우리는 이를 meta/*.json 작성자 래퍼 (writer wrapper)로 일반화하여 여덟 번째 버그를 잡아낼 수 있을지 시도해 볼 것입니다.

후기

우리는 4번째와 5번째 감사 사이클 (audit cycle)을 거의 실행하지 않을 뻔했습니다. 매번 반복되는 실행은 중복처럼 느껴집니다. '분명히 방금 확인했는데'라는 생각이 들기 때문입니다. 일곱 개의 버그가 발견되었다는 사실은 다시 확인하는 데 드는 비용은 낮고, 발견율 (find rate)은 0이 아니라는 직접적인 증거입니다. 높지는 않지만, 동일한 사이클의 이전 실행들이 방금 승인(sign off)을 마친 코드베이스에서 0이 아니라는 점이 중요합니다.

만약 반복되는 감사 프롬프트 (audit prompt)가 있다면, 이를 유휴 상태 (idle)라고 판단하기 전에 몇 번 더 실행해 보는 것을 고려해 보십시오.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0