
stop()가 멈추지 않았다——AI 에이전트의 '유령 스케줄러'가 48초 동안 신의 회의(神議)를 7회 연속 실행한 이야기
요약
AI 에이전트의 스케줄러 설계 오류로 인해 정지된 인스턴스가 90초 후 부활하여 중복 실행되는 '유령 스케줄러' 현상을 분석합니다. setTimeout과 clearInterval의 불일치로 인해 발생한 토큰 낭비와 설정 파일 덮어쓰기 문제를 다룹니다.
핵심 포인트
- stop() 호출 시 setTimeout을 함께 해제하지 않으면 인스턴스가 재부활함
- 중복 실행된 인스턴스들이 동일한 설정 파일을 덮어써 데이터 무결성 파괴
- 시간 의존적인 버그는 일반적인 테스트 환경에서 재현하기 어려움
- 정지된 인스턴스의 재사용을 구조적으로 방지하는 설계가 필수적임
TL;DR
GodScheduler.stop()
이
clearInterval
만 수행하고,
setTimeout
을 이용한 기동 지연 설정을 멈추는 것을 잊었다 - 설정 저장 시마다
operations.reset()
→ 재init(re-initialization)이 실행되어, 정지되었어야 할 구형 인스턴스가 90초 후에 부활하여 증식했다 - 2026-07-12 00:0000:01 사이의 48초 동안, AI 회의(神議)가 7회 연속 실행(각 2.33.1k 토큰, 총 약 19k 토큰), 승인 배치(approval batch)가 6장 생성되었으며, 수동으로 수정했던
schedule.json
이 이전 상태로 되돌아갔다 - 수정의 핵심은 "타이머를 멈추는 것"이 아니라 "정지된 인스턴스는 쓰지 않는다"를 구조적으로 보장한 것이다.
AMA-teras의 자율 운영과 스케줄러
AMA-teras에는 "신의 시계(AMENO-koyane)"라고 부르는 스케줄러가 있어, 관측·순회·초안 생성·신의 회의(神議, AI 회의)와 같은 운영 태스크를 정기적으로 실행한다. 신의 회의는 LLM을 사용하여 능력 격차나 발신 제안을 내놓고, 이를 승인 배치로서 인간에게 전달한다. 외부로의 발신은 모두 이와토 게이트(岩戸ゲート, 승인 다이얼로그)를 통과하므로, 유령이 몇 마리가 있더라도 외부에 무언가 나가는 일은 없다——그럼에도 LLM은 돌아가므로, 토큰은 실비로 소모된다.
왜 기동 90초 후에 setInterval을 거는 설계였는가
GodScheduler.start()
은 기동 직후의 부하를 피하기 위해 "기동 90초 후에 setInterval을 건다"는 구조로 되어 있었다. 즉 다음과 같다.
start() {
setTimeout(() => {
this.timer = setInterval(() => this.tick(), INTERVAL_MS);
...
stop()
은 clearInterval
만 수행하고 있었다. setInterval을 걸기 전의 90초 사이에 stop()이 호출되면, 멈춰야 할 타이머는 아직 존재하지 않으며, setTimeout만 살아남는다. 90초 후, 해당 콜백이 발화하여 setInterval을 걸게 되고, "정지되었어야 할 스케줄러"가 부활한다.
사건: 2026-07-12 00:00의 48초간
증상 1: 신의 회의 7회 연속 실행과 승인 배치 6장
2026-07-12 00:0000:01 사이의 48초 동안, 신의 회의가 7회 연속으로 실행되었다. 각 2.33.1k 토큰, 총 약 19k 토큰. 승인 배치가 6장 생성되었다.
신의 회의는 본래 스케줄에 따라 간격을 두고 실행되는 태스크다. 48초에 7회라는 밀도는, 동일한 schedule.json을 보고 있는 여러 개의 스케줄러 인스턴스가 각각 독립적으로 "실행 시간이 되었다"고 판단하여 실행되었음을 의미한다.
증상 2: 수동으로 수정한 schedule.json이 되돌아감
또 다른 증상이 기괴했다. M38에서 직접 편집한 schedule.json (신의 회의에 의해 정지되었던 omoi-kami를 enabled=true로 재개한 변경 사항)이, 이전 상태로 되돌아갔다.
이는 유령 스케줄러가 각각 오래된 메모리 상태를 가진 채 파일을 덮어썼기 때문이다. 편집 후에 파일을 읽은 인스턴스는 올바른 상태를 갖지만, 편집 전에 읽은 유령이 나중에 save()를 하면, 디스크 상의 올바른 상태가 오래된 메모리 내용으로 덮어씌워진다.
실제 데이터로 발각되었다——테스트에서는 잡히지 않았던 이유
이 버그는 테스트가 아닌 실제 데이터에서 발각되었다. 재현을 위해서는 "start() → 90초 경과 전 stop() → 90초 경과"라는 시간 의존적인 시퀀스가 필요하며, 게다가 증식의 트리거는 "설정 저장"이라는 얼핏 무관해 보이는 조작이었다. 당시 테스트는 stop() 후에 setInterval이 걸리지 않는다는 점을 검증하지 않았기에, 유령은 운영 환경에서만 살아있었다.
원인: setTimeout 안의 setInterval
설정 저장 시마다 유령이 1개씩 증가
증식 메커니즘은 다음과 같다.
secretsSet
등으로 설정을 저장하면 operations.reset()이 호출된다 - reset은 기존 스케줄러의 stop()
를 호출하지만, setTimeout은 멈추지 않는다 - 다음 액세스 시 재init(재초기화)되어 새로운 스케줄러가 start() 한다.
- 90초 후,
구 인스턴스의setTimeout이 발화하여setInterval을 설정 - 이로 인해 살아있는 스케줄러가 2개가 된다. 설정 저장 시마다 1개씩 증가한다.
모든 유령이 동일한 schedule.json을 오래된 메모리 상태로 덮어씀
각 유령은 동일한 schedule.json을 보고 tick을 돌리기 때문에, 실행 시간이 되면 각자 신의 회의(神議)를 실행한다(→ 48초 동안 7회). 그리고 각자의 메모리 상태로 파일을 덮어쓰기 때문에, 나중에 발화한 유령이 오래된 상태를 다시 써버린다(→ 롤백(rollback)).
토큰을 낭비할 뿐만 아니라, 사람이 수동으로 수행한 운영 상태의 수정을 조용히 지워버리고 있었다는 것이 이 버그의 실질적인 피해였다.
수정: 멈추는 것이 아니라 「쓰지 못하게 하기」
수정은 2단계로 진행했다.
1. startTimer를 유지하여 clearTimeout / stopped 플래그로 부활을 금지
setTimeout의 반환값을 startTimer로 유지하고, stop()에서 clearTimeout을 호출한다. 여기에 더해 stopped 플래그를 세워, 한 번 멈춘 인스턴스는 두 번 다시 start 할 수 없는 **일회용 설계(disposable design)**로 만들었다.
stop() {
this.stopped = true;
clearTimeout(this.startTimer); // 90초 대기 중인 setTimeout도 멈춤
...
2. tick()과 save()를 stopped로 조기 반환(early return) —— 롤백의 구조적 방지
이것이 수정의 핵심이다. 타이머를 빠짐없이 멈추는 것은 '노력'일 뿐 보장이 아니다. 그래서 **tick()과 save()의 서두에서 stopped라면 조기 반환(early return)**하도록 했다.
private tick() {
if (this.stopped) return; // 정지된 상태라면 실행하지 않음
// ...
...
이렇게 하면, 설령 향후에 또 멈추는 것을 잊은 타이머가 생기더라도, 정지된 인스턴스가 파일을 덮어쓰는 것은 구조적으로 불가능해진다. '멈추는 것'의 포괄성에 의존하지 않고, '쓰지 못하게 함'으로써 방어한다.
가짜 타이머(fake timers)를 이용한 재현 테스트 4건
가짜 타이머(fake timers)를 사용하여 「start → 90초 경과 전 stop → 시간을 진행」하는 시퀀스를 재현하였고, 유령이 부활하지 않는 것과 정지된 인스턴스가 쓰지 않는 것을 4개의 테스트로 고정했다.
교훈
라이프사이클(lifecycle)을 가진 객체는 「부활하지 않음」을 테스트하라
start/stop을 가진 객체의 테스트는 「동작하는 것」과 「멈추는 것」만으로 만족하기 쉽지만, 정말 위험한 것은 「멈춘 뒤에 다시 살아나지 않는 것」이다. 특히 지연 실행(setTimeout 안에서 setInterval을 호출하는 경우)과 같은 다단계 타이머는, stop()이 모든 단계를 멈추고 있는지 의심해봐야 한다.
「정지된 인스턴스의 쓰기 금지」는 이중 방벽이 된다
타이머의 clear는 누락될 수 있다. 하지만 「stopped라면 쓰지 않는다」는 타이머가 어떻게 누락되더라도 마지막 보루에서 지켜준다. **부작용(side effect)의 입구(tick / save)에서 상태를 확인하는 조기 반환(early return)**은 라이프사이클 관리 버그에 대한 저렴하고 확실한 방벽이 된다.
실제 비용(약 19k 토큰)이 발생했기에, 실패도 PROGRESS에 남긴다
이 버그는 약 19k 토큰의 실비와 수동 수정의 롤백이라는 실질적인 피해를 냈다. 그렇기에 증상, 원인, 수정을 PROGRESS.md에 그대로 남겨두고 있다. 외부 발신은 이와토 게이트(岩戸ゲート)가 지켜주고 있었기에 사고는 내부에서 완결되었지만, 「가드가 기능했다」는 것과 「버그가 없었다」는 것은 별개의 문제다.
부록: 같은 날에 고친 또 다른 「서로 다른 고장 방식」
동일한 M41에서, 게시물 링크를 여는 방식이 데스크톱과 스마트폰에서 각각 다른 이유로 고장 나 있던 것도 수정했다.
데스크톱: window.open이 앱 내 창을 열고 있었다
window.open이 Electron의 앱 내 창을 열고 있었는데, 그곳에는 X의 로그인 세션이 없어서 사용할 수 없었다. openExternal IPC(http/https만 허용)를 통해 **기본 브라우저(default browser)**로 열도록 변경하였고, 추가로 setWindowOpenHandler
에서 앱 내 창(in-app window) 생성을 자체적으로 금지했다(방어).
스마트폰: 승인 후의 window.open은 팝업 차단으로 반드시 실패
스마트폰(원격 운영)에서는 승인이라는 통신 이후에 window.open을 실행했기 때문에, 사용자 제스처(user gesture)의 문맥이 끊겨 팝업 차단(popup block)에 반드시 걸리고 말았다. 승인 후에 '탭하여 한 장씩 열기 버튼'을 나열하는 방식으로 변경했다. 탭(tap)은 제스처이므로 차단되지 않는다.
똑같은 '링크가 열리지 않는' 현상이라도, 원인은 플랫폼마다 완전히 달랐다. 증상이 같더라도 원인을 개별적으로 추적한다——유령 스케줄러와 더불어, 이날의 교훈은 그것으로 요약된다.
Discussion

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