메시지가 봇에게 도착해서 답이 나가기까지의 경로를 정리하려고 공식 문서 두 편을 읽었다. 정리해놓고 보니 확인하고 싶은 게 생겨 컨테이너에 들어가 소스를 열었는데, 거기서부터 네 번 뒤집혔다. 그중 두 개는 코드 자신의 주석과 docstring이 코드와 어긋난 경우였다.
이 글이 다루는 것
에이전트가 답을 만드는 과정에 대한 글은 전에 몇 편 썼다. 이 글은 그 앞쪽이다. 메시지가 플랫폼에서 들어와 에이전트에 닿기까지 통과하는 관문들 — 어댑터 필터, 두 겹의 동시성 가드, 인가 사다리, DM 페어링.
읽은 문서는 developer-guide/gateway-internals와 developer-guide/session-storage 두 편이다.
문서는 잘 쓰여 있고 대부분 정확하다. 다만 정확하지 않은 네 곳이 하필 운영 판단이 걸린
지점들이었다.
배경 — 게이트웨이와 어댑터
Hermes의 메시징 게이트웨이는 20개 이상 플랫폼을 하나의 본체로 처리한다. 플랫폼마다 다른 부분은 전부 어댑터에 있고, 어댑터는 네 개의 메서드로 요약된다.
| 메서드 | 언제 | 하는 일 |
|---|---|---|
connect() |
시작 시 1회 | WebSocket 연결 + 토큰 락 획득 |
on_message() |
메시지마다 | 플랫폼 고유 JSON을 MessageEvent로 번역 |
send_message() |
답변마다 | 반대 방향. Hermes → 플랫폼 |
disconnect() |
종료 시 1회 | 토큰 락 해제 |
on_message()가 하는 일은 통역이다. Mattermost가 던지는 이벤트와 Discord가 던지는 것이
전혀 다른 모양인데 이걸 전부 하나의 MessageEvent로 바꿔주기 때문에, 본체는 출처를 몰라도
된다. 플랫폼을 20개 지원하면서 본체가 하나인 이유다.
전체 왕복은 이렇게 생겼다.
① 플랫폼 서버 → WebSocket 이벤트
② 어댑터 on_message() — 번역 + 1차 필터
③ 베이스 어댑터 — 가드 1층 (실행 중이면 큐잉)
④ GatewayRunner._handle_message() — 세션 키, 인가, 슬래시 명령, 가드 2층
⑤ SessionStore — SQLite에서 이전 대화 로드
⑥ AIAgent 실행
⑦ delivery.py — 전송 경로 결정
⑧ 어댑터 send_message()
문서를 읽고 여기까지는 그림이 맞았다. 틀린 건 각 관문의 내용이었다.
정정 ① require_mention은 러너가 아니라 어댑터에 있다
채널에서 봇이 @멘션 없이는 반응하지 않게 하는 설정이다. 나는 이걸 "러너 단계 필터"로
정리해뒀었다. 근거는 문서의 "플랫폼 차이는 어댑터에만 있고 나머지는 공용 경로"라는 서술이었다.
어댑터가 아니라 공용 경로에 있으니 전 플랫폼에 동일하게 적용된다고 읽은 것이다.
확인해보니 반대였다.
$ grep -rln "require_mention" /opt/hermes --include=*.py
/opt/hermes/plugins/platforms/mattermost/adapter.py
/opt/hermes/plugins/platforms/slack/adapter.py
/opt/hermes/plugins/platforms/telegram/adapter.py
/opt/hermes/plugins/platforms/discord/adapter.py
...
$ grep -n "require_mention" /opt/hermes/gateway/run.py
(출력 없음)
러너에는 한 줄도 없다. 구현은 어댑터 안이다.
require_mention = os.getenv(
"MATTERMOST_REQUIRE_MENTION", "true"
).lower() not in {"false", "0", "no"}
전 플랫폼에 동일하게 적용된다는 결론은 맞다. 이유가 틀렸다. 공용 코드라서가 아니라 어댑터마다 같은 것을 각자 재구현했기 때문이다.
사소해 보이지만 실무에서 갈린다. 공용 코드라면 새 플랫폼을 붙일 때 자동으로 따라오고 동작이
보장된다. 재구현이라면 플랫폼마다 옵션 이름도 세부 동작도 다를 수 있다. 실제로 Slack에는
strict_mention이라는 모드가 하나 더 있다.
그리고 필터가 어댑터에 있다는 건 메시지가 MessageEvent가 되기도 전에 버려진다는 뜻이다.
로그에 logger.debug 한 줄만 남는다.
정정 ② 인가 검사에서 페어링이 허용목록보다 먼저다
공식 문서가 적어놓은 인가 순서는 이렇다.
- 플랫폼별 allow-all 플래그
- 플랫폼 허용목록
- DM 페어링
- 전역 allow-all
- 기본 거부
인가 함수의 docstring도 똑같은 순서를 적어놓았다. 문서와 코드 주석이 일치하니 더 볼 것이 없다고 생각했는데, 정작 그 아래 코드가 달랐다.
# ① 플랫폼 ALLOW_ALL
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true","1","yes"}:
return True
# ② 페어링 스토어 ← 허용목록보다 먼저
pairing_store = self._pairing_store_for(source)
if pairing_store is not None and pairing_store.is_approved(platform_name, user_id):
return True
# ③ 허용목록
platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip()
순서만 다른 게 아니었다. 같은 함수의 다른 주석이 의도를 밝히고 있었다.
Honored as a UNION with the allowlist: a paired user is authorized regardless of the allowlist
두 관문은 순차 탈락 구조가 아니라 합집합이다. 둘 중 하나만 통과하면 인가된다.
허용목록에 있음 → 통과
페어링됨 → 통과 (허용목록에 없어도)
둘 다 아님 → 거부
이게 왜 헷갈렸냐면, "앞에서 걸리면 뒤는 안 본다"는 표현을 "탈락하면 거기서 끝"으로 읽기 쉽기 때문이다. 실제 의미는 "통과시키면 더 안 본다"에 가깝다. 각 관문은 통과시킬 근거가 있는지만 보고, 없으면 다음으로 넘긴다. 최종 거부는 전부 통과하지 못했을 때만 나온다.
같은 함수의 docstring 하나와 주석 하나가 서로 다른 이야기를 하고 있었던 셈이다. 이런 경우 주석보다 코드, 그리고 일반 서술보다 구체적 주석이 맞을 확률이 높다는 게 이번 교훈이다. "UNION"이라고 적힌 주석은 그 분기 바로 위에 있었고, 순서를 적은 docstring은 함수 맨 위에 있었다. 가까이 있는 주석이 이겼다.
정정 ③ 페어링 코드는 미인가 사용자가 받는다
DM 페어링은 허용목록에 없는 사람에게 접근 권한을 주는 장치다. 나는 흐름을 이렇게 그렸었다.
✗ 관리자가 /pair 실행 → 코드 받음 → 신규 사용자에게 전달 → 사용자가 봇에게 입력
전부 반대였다.
① 미인가 사용자가 봇에게 DM (아무 내용이나)
② 봇이 그 사용자에게 답장:
Hi~ I don't recognize you yet!
Here's your pairing code: `K7M2PQ9X`
Ask the bot owner to run:
`hermes pairing approve mattermost K7M2PQ9X`
③ 사용자가 코드를 관리자에게 전달 (메신저 밖에서)
④ 관리자가 서버에서 CLI로 승인
사용자가 코드를 입력하는 단계가 없다. 코드는 권한이 아니라 "이 요청을 승인해달라"는 티켓
번호다. 코드 자체로는 아무것도 열리지 않는다. 인가 코드의 주석도 이걸 못 박고 있다.
"an inbound sender can never reach approve_code".
거꾸로 생각해보면 이 설계가 맞다. 관리자가 먼저 코드를 만드는 방식이라면 관리자가 대상자의 플랫폼 사용자 ID를 미리 알아야 한다. 사용자가 먼저 말을 걸게 하면 그 ID가 자동으로 잡힌다. 실제로 발급된 코드는 그 사용자 ID에 묶여 저장된다.
값도 들여다볼 만하다.
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 32자
CODE_LENGTH = 8
0/O, 1/I/L 같은 혼동 문자를 뺐다. 사람이 구두로 전달할 것을 전제한 알파벳이다.
저장되는 형태:
{
"a3f9c81b22d4e07f": {
"hash": "<salted SHA-256>",
"salt": "<16바이트 hex>",
"user_id": "<플랫폼 사용자 ID>",
"user_name": "<표시명>",
"created_at": 1753900000.0
}
}
코드가 평문으로 저장되지 않는다. 키도 코드가 아니라 별도 랜덤 ID다. 파일을 읽어도 코드를 알 수 없다. 여기에 만료 1시간, 사용자당 10분에 1회 요청, 대기 코드 최대 3개, 승인 5회 실패 시 1시간 잠금이 겹친다. 8자 × 32종이면 1조 가지가 넘는데 잠금까지 있으니 무차별 대입은 의미가 없다.
한 가지 더. 페어링 상태는 .env가 아니라 별도 JSON에 산다. 이게 운영에서 큰 차이를 만든다.
허용목록(.env) |
페어링 | |
|---|---|---|
| 반영 | 게이트웨이 재시작 필요 | 즉시 |
| 추가 주체 | 사람이 서버에서 직접 편집 | 사용자 요청 → 관리자 승인 |
.env는 기동 시 1회만 읽힌다. 채널마다 프로필을 따로 두는 구성이라면 사용자 한 명 추가에
프로필 수만큼 재시작이 필요하다. 페어링 스토어는 매 요청 조회라 재시작이 없다.
정정 ④ pid 파일은 숫자가 아니라 JSON이고, 컨테이너 PID다
게이트웨이는 자기 프로세스 ID를 파일에 적어둔다. stop이 누구를 죽일지 알아야 하기 때문이다.
나는 "숫자 한 줄이 들어있다"고 설명했는데 열어보니 아니었다.
{"pid": 150, "kind": "hermes-gateway",
"argv": ["/opt/hermes/.venv/bin/hermes", "gateway", "run", "--replace"],
"start_time": 2368}
kind와 argv와 start_time이 왜 같이 들어있는지가 흥미롭다. PID는 재사용되기 때문이다.
150번이 죽고 한참 뒤 전혀 무관한 프로세스가 150번을 받을 수 있다. 그때 stop이 파일만 믿고
죽이면 엉뚱한 걸 죽인다. 그래서 죽이기 전에 argv와 시작 시각까지 대조한다.
그리고 이 PID는 컨테이너 네임스페이스 값이다.
컨테이너 안: s6-svscan → PID 1
호스트에서: s6-svscan → PID 2373
같은 프로세스인데 번호가 다르다. 파일에 적힌 값을 호스트에서 kill하면 무관한 것을 죽인다.
macOS라면 한 겹 더 있다. Docker Desktop이 리눅스 VM을 돌리므로 macOS의 ps로는 컨테이너
프로세스가 아예 보이지 않는다. 확인은 이렇게 한다.
docker exec <컨테이너> ps -eo pid,ppid,user,args | grep 'gateway run' # 컨테이너 관점
docker top <컨테이너> # 호스트 관점
부수 소득이 하나 있었다. 프로필 5개의 start_time이 두 무리로 갈렸다 — 둘은 2368~2369,
셋은 129773 이상. 프로필 단위로 재시작한 흔적이다. 전체를 pkill로 날린 적이 없다는 증거이기도
하다. 재시작 이력을 따로 남기지 않았는데 파일이 대신 기억하고 있었다.
가드는 봇 단위가 아니라 세션 단위다
여기부터는 정정이 아니라 새로 확인한 것들이다.
에이전트가 답하는 데 시간이 걸리기 때문에, 처리 중에 들어온 메시지를 어떻게 할지 정하는 장치가 있다. 두 겹이다.
- 1층(어댑터) — 활성 세션이면 대기줄에 넣고 인터럽트 신호만 세팅. 러너까지 안 간다.
- 2층(러너) —
/stop,/new,/queue,/status,/approve,/deny를 가로채고 나머지는 인터럽트.
/approve가 예외 통로에 있는 이유가 이 설계의 핵심이다.
14:00:05 봇: ⚠️ 실행할까요? rm -rf ... /approve 또는 /deny
← 봇이 "멈춰서 기다리는" 상태
14:00:20 사용자: /approve
봇은 "실행 중"이라 일반 메시지는 전부 대기줄로 간다. 그런데 봇이 기다리는 게 바로 그 승인
메시지다. /approve가 큐에 갇히면 서로를 기다리는 교착이 된다. 그래서 승인과 거부만은
백그라운드 태스크 시스템을 우회해 인라인으로 처리된다.
그럼 여러 명이 동시에 한 봇을 쓰면 서로 막힐까? 가드가 세션 단위라서 설정에 달렸다.
group_sessions_per_user: true # 기본값
thread_sessions_per_user: false # 기본값
앞의 것이 켜져 있으면 채널에서 참여자마다 세션 키가 갈린다.
채널 A, 사용자 갑 → ...:channel:<채널ID>:<갑ID>
채널 A, 사용자 을 → ...:channel:<채널ID>:<을ID>
키가 다르면 다른 세션이고, 가드도 따로 걸린다. 갑이 처리 중이어도 을은 안 막힌다.
스레드는 반대다. 뒤의 설정이 기본 false라 스레드 안에서는 사용자 ID를 키에 붙이지 않는다.
참여자 전원이 한 세션을 쓰므로 서로 대기한다. 대신 봇이 스레드 전체 맥락을 이해한다. 의도된
트레이드오프다.
여기서 놓치기 쉬운 게 있다. 세션이 갈린다는 건 기억도 갈린다는 뜻이다. 갑이 봇에게 알려준
것을 을이 물으면 봇은 모른다. 채널 지식을 공유시키려면 group_sessions_per_user: false로
바꿔야 하는데, 그러면 동시 사용 시 서로 막힌다. 둘 다는 안 된다.
DM은 멘션·채널 게이트를 우회한다
정리하다가 "채널 초대로 접근을 통제하고 있다"고 적었는데, 곧바로 반례가 떠올랐다. Mattermost는 같은 팀이면 봇을 검색해 DM을 보낼 수 있다. 채널 초대와 무관하다.
어댑터 코드가 이걸 명시적으로 만들고 있었다.
if channel_type_raw != "D": # D = Direct Message
# ... 채널 허용목록 검사 ...
# ... @멘션 검사 ...
DM은 이 블록 전체를 건너뛴다. 즉 DM에서는 @멘션이 필요 없고, 채널 허용목록도 적용되지 않는다.
그 경로의 유일한 방어선은 사용자 허용목록이다. 채널 통제는 여기에 아무 방어도 하지 못한다. 계정 하나가 탈취되면 채널 초대 없이 봇 전체를 쓸 수 있다는 뜻이기도 하다.
로그 쪽에도 사각이 하나 있다. 어댑터의 멘션 검사가 러너의 인가 검사보다 먼저 일어나므로,
권한 없는 사람이 채널에 @멘션 없이 글을 쓰면 어댑터에서 logger.debug 한 줄로 사라진다.
"권한 없는 사용자가 시도했다"는 기록이 남지 않는다. 인가 실패가 경고로 남는 건 DM이거나
멘션이 있어서 러너까지 도달한 경우뿐이다.
기본값이 우리 맥락과 어긋날 때
페어링을 파다가 발견한 것이다. 미인가 DM에 코드를 줄지 말지는 설정 하나가 정한다.
def _normalize_unauthorized_dm_behavior(value, default: str = "pair"):
if normalized in {"pair", "ignore"}:
return normalized
return default
기본값이 pair다. 그리고 우리는 이 값을 어디에도 설정한 적이 없었다. 즉 지금 상태를
문장으로 옮기면 이렇다.
팀 내 누구든 봇에게 DM을 보내면, 봇이 "안녕하세요, 페어링 코드는 K7M2PQ9X입니다"라고 답한다.
승인 없이는 아무것도 못 하니 권한이 새는 건 아니다. 다만 봇이 살아있다는 걸 아무나 확인할 수 있고, 관리자에게 승인 요청이 들어오는 통로가 열려 있다.
이 기본값이 잘못됐다는 얘기는 아니다. Telegram 같은 공개 플랫폼에서는 합리적이다. 모르는 사람이 말을 걸면 셀프서비스 신청 경로를 안내하는 것이다. 문제는 전제가 다른 곳에 갖다 썼을 때다. 사내 메신저에는 이미 접근 요청 경로가 있다(관리자에게 말하면 된다). 봇은 조용한 편이 낫다.
hermes -p <프로필> config set unauthorized_dm_behavior ignore
이건 기술 판단이 아니라 운영 방침이라 아직 결정하지 않고 남겨뒀다.
그 밖에 확인한 것들
정정은 아니지만 같이 짚어둘 것들이다.
설정 소스가 셋이고, CLI와 게이트웨이가 다르게 읽는다
| 소스 | 제공하는 것 |
|---|---|
.env |
API 키, 봇 토큰, 플랫폼 자격증명 |
config.yaml |
모델 설정, 툴 설정, 표시 옵션 |
| 환경변수 | 위 둘을 전부 덮어씀 |
여기까지는 흔한 구조인데, 문서가 덧붙인 한 문단이 중요했다.
Unlike the CLI (which uses
load_cli_config()with hardcoded defaults), the gateway readsconfig.yamldirectly via YAML loader. This means config keys that exist in the CLI's defaults dict but not in the user's config file may behave differently between CLI and gateway.
CLI와 게이트웨이가 설정을 읽는 경로 자체가 다르다. CLI는 하드코딩된 기본값 딕셔너리를 거치고, 게이트웨이는 YAML을 직접 읽는다. 그래서 사용자 파일에 없는 키가 양쪽에서 다르게 동작할 수 있다.
이게 왜 중요하냐면, 봇이 무응답일 때 가장 먼저 하는 게 CLI로 모델을 찔러보는 것이기 때문이다.
hermes chat -q 'say OK in one word'
여기서 답이 오면 "모델은 살아있다"까지는 맞다. 하지만 게이트웨이가 건강하다는 증거는 아니다. 전에 겪은 장애에서 이 착각으로 시간을 썼는데, 구조적 근거가 여기 있었다.
플랫폼별 설정도 비슷한 층이 하나 더 있다. config.yaml의 플랫폼 블록은 기동 시 환경변수로
번역된다.
if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"):
os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower()
두 경로 모두 동작하지만 not os.getenv(...) 가드 때문에 환경변수가 이긴다. 둘 다 설정해
두고 YAML만 고치면 반영되지 않는다. 한쪽만 써야 한다.
슬래시 명령은 별칭·접두 매칭을 거친다
명령 처리가 단순 문자열 비교가 아니었다.
resolve_command()가 입력을 정규 이름으로 바꾼다 (별칭, 접두 매칭 처리)- 정규 이름을 알려진 명령 목록과 대조
- 핸들러가 정규 이름 기준으로 분기
- 일부 명령은 설정에 따라 잠긴다
접두 매칭이 있다는 건 /st가 /status로 풀릴 수 있다는 뜻이다. 편하지만, 명령을 새로
추가할 때 기존 접두사와 충돌하지 않는지 봐야 한다는 뜻이기도 하다.
훅은 자리만 있고 비어 있다
생명주기 이벤트에 자기 코드를 붙일 수 있다.
| 이벤트 | 발화 시점 |
|---|---|
gateway:startup |
게이트웨이 시작 |
session:start / session:end / session:reset |
세션 생명주기 |
agent:start / agent:step / agent:end |
agent:step은 툴 호출 한 바퀴마다 |
command:* |
슬래시 명령 실행 |
기본 제공 훅은 하나도 없다. 등록 함수가 아무것도 하지 않는 빈 스텁이다. 순수하게 "직접 만들어 넣으라"는 자리다.
쓸모 있을 만한 아이디어가 하나 떠올랐다. 전에 겪은 장애 중에 자격증명 파일의 소유권이
바뀌어 게이트웨이가 조용히 빈 인증으로 시작한 건이 있었다. gateway:startup 훅에서
그 파일 소유권을 검사해 경고를 띄웠다면 기동 시점에 알았을 것이다.
나가는 길은 네 갈래다
들어오는 경로만 보다가 나가는 쪽도 갈래가 있다는 걸 알았다.
- 직접 답장 — 질문이 온 채널로 (대부분)
- 홈 채널 — 크론 결과처럼 물어본 사람이 없는 경우 미리 정한 채널로
- 명시적 타깃 —
telegram:-1001234567890형태. 셸 스크립트에서 쓰는 전송 CLI와 크론 대상 - 크로스 플랫폼 — 한 플랫폼에서 온 것을 다른 플랫폼으로
그리고 한 줄짜리 설계 결정이 있었다. 크론 전송은 게이트웨이 세션 히스토리에 미러링되지 않는다. 자기 크론 세션에만 남는다. 이유는 메시지 교대(alternation) 위반을 피하기 위해서다. 모델 API는 user/assistant가 번갈아 오는 걸 기대하는데, 아무도 묻지 않은 봇 발화를 대화 기록에 끼워 넣으면 그 순서가 깨진다.
어댑터를 안 만들고 플랫폼을 붙이는 길
어댑터 목록을 보다가 irc/에 "scoped-lock의 정본 예시"라는 설명이 붙어 있는 걸 발견했다.
토큰 락 구현을 보려면 그쪽이 가장 짧다.
더 흥미로운 건 릴레이였다. 커넥터 기반 플랫폼은 전용 어댑터 대신 범용 릴레이를 쓴다.
릴레이 URL이 설정되면 게이트웨이가 커넥터로 아웃바운드 WebSocket을 걸고, 그 한 소켓으로
세 종류의 프레임을 받는다 — 커넥터가 자기 능력을 알리는 descriptor, 들어온 메시지
inbound, 그리고 interrupt_inbound. 되돌려 보낼 수 있는 것에는 토큰이 필요 없는
follow_up 연산도 있다.
방향이 아웃바운드라는 게 핵심이다. 게이트웨이가 먼저 다이얼하므로 인바운드 포트를 열 필요가 없다. 사내 시스템을 붙일 때 어댑터를 새로 쓰지 않아도 되는 경로이기도 하다.
정정 ②에서 본 "신뢰된 상류가 이미 인가했다"는 분기가 바로 이 경로 몫이었다. 릴레이 커넥터가 인증·인가를 마치고 넘긴 것이라 게이트웨이는 로컬 허용목록을 다시 보지 않는다. 주석이 "fail-open이 아니라 위임"이라고 길게 변호해둔 것도 그래서다.
왜 이런 어긋남이 생기나
네 건을 놓고 보니 패턴이 있었다.
① 문서가 코드보다 느리다. 어댑터가 플러그인으로 이동하면서 require_mention도 따라
움직였을 가능성이 높다. 문서의 "플랫폼 차이는 어댑터에만" 서술은 그 자체로는 여전히 맞지만,
독자가 거기서 추론한 것이 틀리게 됐다.
② docstring은 의도를, 코드는 현실을 적는다. 인가 순서 건이 그랬다. docstring의 순서는 아마 처음 설계 의도였을 것이고, 나중에 페어링을 UNION으로 바꾸면서 코드만 움직였다. 그 변경을 설명하는 주석은 바뀐 자리 바로 옆에 새로 붙었다.
③ 이름이 흐름을 오해하게 만든다. "페어링 코드"라는 이름은 관리자가 발급하는 초대 코드처럼 들린다. 실제로는 사용자가 받는 신청 번호다.
④ 요약은 구조를 지운다. "pid 파일"이라는 이름을 들으면 PID가 들어있다고 생각한다. 왜 그 이상이 필요한지(PID 재사용)는 파일을 열어봐야 보인다.
배운 것
- 결론이 맞아도 이유가 틀리면 다음 판단이 틀린다.
require_mention이 전 플랫폼에 적용된다는 결론은 처음부터 맞았다. 이유를 잘못 알아서 "그럼 새 플랫폼도 자동으로 되겠네"라는 다음 추론이 틀리게 됐다. - 같은 파일 안에서도 주석끼리 다툰다. 그럴 땐 변경 지점에 가까운 주석이 이긴다.
- 문서에 없는 것을 확인하는 게 더 오래 걸린다.
grep이 아무것도 반환하지 않는 것을 근거로 삼으려면 검색 범위가 맞는지부터 의심해야 한다. 이번엔 파일 목록을 먼저 뽑아 어디에 있는지 확인하고, 그다음 없는 곳을 확인하는 순서로 갔다. - 운영 중인 시스템의 상태 파일은 이력을 기억하고 있다.
start_time두 무리가 재시작 이력을 말해줬다. 로그를 뒤지기 전에 상태 파일을 먼저 볼 이유가 하나 늘었다.
가장 크게 남은 건 마지막이다. 접근 통제가 어디에 걸려 있는지 안다고 생각했는데 아니었다. 채널 초대로 막고 있다고 믿었지만 실제 방어선은 다른 곳에 있었고, 그마저도 기본값 하나 때문에 생각보다 열려 있었다. 이건 문서를 읽어서는 절대 나오지 않았을 결론이다.
관련 글
- SQLite 파일 하나로 버티는 세션 저장소 — 쓰기 경합, 캐시용 바이트 사본, 안 지워지는 청소 명령 — 같은 세션의 뒷부분. 이 글의 세션 키가 실제로 어디에 저장되고 그 저장소가 어떻게 버티는지.
- Hermes 내부를 열어보다 — 프롬프트 3계층, API 모드 분기, 게이트웨이를 하나만 띄워야 하는 이유 — 이 글의 정정 ①이 그 글의 서술 하나를 뒤집는다. 게이트웨이 격리와 프롬프트 구조는 그쪽에 있다.
- 에이전트 루프 완전 해부 — 질문 32개로 훑은 프로바이더·툴·콜백·폴백·컨텍스트·비용 — 이 글이 다루는 관문들을 전부 통과한 다음에 벌어지는 일.
- 에이전트는 무엇을 기억하고 언제 잊는가 — 메모리 스냅샷, 세션 수명, 그리고 멈춰 있던 자가개선 루프 — 세션 키가 사용자별로 갈린다는 사실을 "무엇이 기억되는가" 쪽에서 먼저 확인한 글. 이 글은 같은 사실을 "누가 언제 대기하는가" 쪽에서 본다.
- 주말에 봇이 조용히 죽었다 — 1시간짜리 토큰을 6시간마다 갱신하는 스케줄러 — 플랫폼 자격증명과 모델 자격증명은 다르다. 전자가 죽으면 그 채널만, 후자가 죽으면 전 채널이 함께 죽는다.
- 봇 전체 무응답 — 무료 모델 소멸(404) + auth.json 권한 사고(401) — CLI에서 잘 되는데 봇이 무응답이던 사례. 게이트웨이와 CLI가 설정을 다르게 읽는다는 사실이 배경에 있다.