agent_settled는 '답변 완료'라는 뜻이 아니었다
2026년 9월 30일 수정 전, 텔레그램으로 보낸 블로그 작업 요청 다섯 건이 모두 최종 답변 없이 끝났다. 요청은 다섯 건 모두 세션에 도착해 있었다. 수정 보고서가 짚은 브리지 쪽 문제는, 세션의 agent_settled 이벤트가 오면 최종 답변이 없어도 요청을 끝내고 폴백을 보내는 흐름이었다.
대상은 메신저 요청을 에이전트 세션에 넣고 답을 다시 메신저로 돌려보내는 브리지를 직접 만든 개발자다. 목표는 턴이 끝났다는 신호와 최종 답변이 생겼다는 사실을 따로 판정하는 것이다. 아래는 9월 30일 내 브리지 한 건의 수정 기록이다. 모든 에이전트 런타임의 이벤트가 이렇게 동작한다는 뜻은 아니다.
기록된 증상: 다섯 요청이 모두 도착했는데 답이 없었다
수정 보고서에 남은 원인은 이렇다. 기존 흐름은 요청의 user 메시지가 시작된 뒤 agent_settled가 오면, 최종 assistant 텍스트가 없어도 바로 요청을 끝내고 폴백을 전송했다. 실제 요청 파일과 상태 파일이 만들어진 간격도 4~7초뿐이었다.
보고서가 대조한 세션 기록에서 다섯 요청은 모두 세션에 들어가 있었다. 한 건은 Claude 인증 차단, 나머지 네 건은 로드된 런타임의 OpenAI chunk 누락 오류가 난 뒤 assistant 최종 메시지가 없었다. 마지막 요청도 세션에서 유실된 것이 아니었다. 요청 표시가 세션 기록에 있었고, 바로 다음 줄에 provider 모듈 오류가 있었다.
정리하면 이 사고에서 두 가지가 겹쳤다. 하나는 provider 쪽 오류로 답변이 만들어지지 않은 것이다. 다른 하나는 브리지가 그 상황을 곧바로 "끝"으로 처리한 것이다. 두 오류가 왜 났는지는 이 보고서에 없다. 이 글은 두 번째, 브리지의 판정만 다룬다.
보고서는 이번 증상과 별개로 위험이 하나 더 있었다고 적었다. 기존 주입은 세션이 바쁜 상태로 바뀌는 경쟁 상황에 필요한 deliverAs를 지정하지 않았다. 확장 API의 실제 비동기 실패는 fire-and-forget이라 브리지가 직접 확인할 수도 없었다. 보고서는 이 조합을 바쁜 상태로 바뀔 때의 별도 유실 위험으로 분류했다.
턴 종료와 최종 답변은 다른 신호다
테스트 fixture에서 정상 요청은 이렇게 끝난다. 주입한 user 메시지가 시작되고(message_start), stopReason: "stop"과 텍스트를 가진 assistant 메시지가 끝나고(message_end), 세션이 한가해진 상태에서 agent_settled가 온다. 그때 브리지가 그 텍스트를 원래 텔레그램 메시지에 회신한다.
agent_settled는 이 순서의 마지막 신호일 뿐이다. 테스트에는 assistant 텍스트 없이 agent_settled만 오는 경우와, 빈 settlement 뒤에 최종 텍스트가 따로 오는 경우가 모두 있다. 그래서 회신 여부는 agent_settled가 아니라 최종 assistant 텍스트가 왔는지로 정해야 했다.
9월 30일에 고친 것
- 답변 마감: 답변 마감 시간을 90초로 두었다. 최종 답변 없는
agent_settled에서는 바로 폴백하지 않는다. 최종 텍스트가 오면 바로 회신하고, 오지 않으면 마감 시점에만 폴백한다. - followUp 주입: 모든 세션 주입을
deliverAs: "followUp"으로 보낸다. 세션이 바쁜 상태로 바뀌는 중에도 현재 작업 뒤에 큐잉되게 하려는 것이다. - pending 보존: 동기 주입 실패를
failed로 끝내지 않고pending으로 남긴다. 다음 idle edge와 확장 재로드 뒤에 다시 처리한다. - 모듈 갱신: 확장 진입점의 import query를 새 버전으로 올렸다. OpenClaw 플러그인은 바꾸지 않았다.
90초는 이 구현에 둔 마감 값이다. 브리지가 90초를 기다린다는 뜻도, 요청 처리에 보통 90초가 걸린다는 뜻도 아니다. 마감 안에 최종 텍스트가 오면 바로 회신한다.
상태 전이를 테스트로 고정했다
테스트는 실제 타이머 대신 가짜 deadline을 주입한다. scheduleDeadline이 콜백을 목록에 넣기만 하고, 테스트가 runDeadline()으로 그 콜백을 직접 실행한다. 그래서 90초를 실제로 기다리지 않고도 마감 전과 마감 후를 결정적으로 나눠 볼 수 있다.
| 상황(테스트) | 브리지의 동작 | 상태 파일 |
|---|---|---|
| 세션이 바쁠 때 요청 도착 | 주입하지 않고 기다렸다가, 한가해진 뒤 agent_settled에서 한 번만 주입 | — |
| 정상 요청 | assistant 텍스트를 원 메시지에 회신하고 송신 기록(notifications/) 저장 | sent |
user 메시지 시작 뒤 텍스트 없이 agent_settled | 아무것도 보내지 않음. 마감 콜백을 실행해야 "최종 답변을 확인하지 못했습니다" 폴백 전송 | submitted |
| 빈 settlement 뒤 최종 텍스트 도착 | 다음 agent_settled에서 그 텍스트("실제 최종 답변")를 원 메시지에 회신 | — |
| 동기 주입 실패 | 주입하지 않고 남겨 두었다가, 다음 idle edge의 agent_settled에서 다시 주입 | pending |
| 주입 실패 뒤 브리지 재로드 | 재로드한 브리지가 session_start에서 그 요청을 followUp으로 다시 주입 | — |
| 송신 실패 | 한 번만 시도하고 자동으로 다시 보내지 않음 | failed |
요청이 도착해 주입되고, provider 결과가 최종 텍스트로 나오고, 그 텍스트가 송신 기록으로 남는 각 단계를 한 줄씩 따로 검사하는 구조다. 3번째 줄이 이번 사고를 막는 회귀 테스트다. 그 테스트는 settlement 직후 전송 수가 0이라고 단언하고, 실패 메시지로 "settlement without an answer must not send an immediate fallback"을 남긴다.
코드와 테스트로는 확인했고, 적용에는 /reload가 필요했다
수정 전 focused test는 22개 중 18개만 통과했다. 실패 4개는 누락된 followUp, 즉시 폴백 2건, 주입 실패가 failed로 끝나는 경우였다. 수정 후 focused test는 23/23 통과였다. 당시 전체 suite는 아래 명령(보고서에 기록된 그대로)으로 돌렸다.
node --test --experimental-test-coverage image-attachments.test.mjs integration.test.mjs session-bridge.test.mjs openclaw/index.test.mjs
tests 44
pass 44
fail 0
all files: line 94.97%, branch 87.09%, funcs 94.12%
변경한 JS/MJS 파일 4개는 모두 node --check를 통과했다. LSP는 typescript-language-server가 설치되지 않아 돌리지 못했다.
여기까지가 "수정"의 범위다. 검증할 때 /reload, 소비 세션 재시작, 텔레그램 발송, 실제 요청·상태 파일 수정은 하지 않았다. 보고서는 실행 중인 소비 세션이 이전 모듈을 계속 쓰므로 그 세션에서 /reload를 한 번 실행해야 수정이 적용된다고 적었다. OpenClaw 플러그인은 바뀌지 않아 gateway 재시작은 필요 없었다.
보고서가 적어 둔 수동 확인 방법은 이렇다. 재로드 뒤 세션이 다른 tool 작업으로 바쁜 동안 텔레그램에서 /blog 연결 확인을 보낸다. 기대 결과는 요청이 현재 작업 뒤 follow-up으로 실행되고, 실제 assistant 답변이 원 텔레그램 메시지에 회신되는 것이다. provider 오류로 답변이 생성되지 않더라도 4~7초 만에 폴백하면 안 되고, 90초 마감 뒤에만 폴백해야 한다. 이 확인을 실제로 했는지와 그 결과는 이 기록에 없다.
이 기록이 답하지 않는 것
- Claude 인증 차단과 OpenAI chunk 누락 오류가 생긴 이유
- 실행 중인 세션에서 실제로
/reload를 한 시점과 그 뒤 수동 확인 결과 - 다른 에이전트 런타임의 종료 이벤트가 같은 의미인지
같이 보면 좋은 글
텔레그램 요청이 세션으로 들어가는 구조와 같은 요청을 두 번 실행하지 않는 방법은 텔레그램 한 줄로 블로그 작업을 넘길 때 중복 실행을 막는 구조에 정리했다. 테스트가 모두 통과해도 실제 실행 결과는 따로 확인해야 하는 이유는 테스트 113개 다 통과했는데 실제론 0개를 긁었다 — AI가 짠 수집기의 함정에서 다른 사례로 다뤘다.
결론: 턴이 끝났다는 신호를 답변으로 읽지 않는다
9월 30일 수정 전의 다섯 요청은 모두 세션에 도착했지만 최종 답변이 없었고, 당시 브리지는 답변 없는 agent_settled를 완료로 보고 바로 폴백을 보내는 구조였다. 9월 30일에는 90초 답변 마감, followUp 주입, pending 보존으로 바꾸고, 즉시 폴백·분절 답변·주입 실패 재시도·재로드 복구를 회귀 테스트로 재현해 전체 44개 통과를 확인했다. 다만 이는 코드와 테스트 기준의 수정이고, 실행 중인 세션에 적용하려면 /reload가 필요했다.
댓글
댓글 쓰기