결론부터. 빈 세션은 daemon이 만드는 게 아니다. 원인은 worker(LAX-site) 쪽 상태를 바꾸는 GET 엔드포인트 하나다. daemon은 chat_sessions에 INSERT를 하지 않는다(폴링·하트비트·ingest·complete만 한다). 추적 결과를 정리한다.
1) 세션 레코드 생성 트리거 — "클릭 즉시 INSERT"가 맞다
chat_sessions INSERT는 전부 createChatSession()(src/shared/chat.ts:33)을 거친다. "최근 세션" 목록에 뜨는 "제목 없음 · 0토큰" 의 발원지는 명확하다:
GET /chat/sessions/new?agent_id=<id> (src/app/chat/routes.tsx:852)
→ createChatSession(... title 없음 ...) ← 즉시 INSERT (line 864)
→ c.redirect(/chat/sessions/<id>) ← 빈 방으로 이동 (line 870)
- "+ 새 세션"은
<a href="/chat/sessions/new?...">링크다 (UI 3곳: line 212, 228, 361). - 즉, 첫 메시지 전송과 무관하게, 그 URL에 도달하는 순간 빈 행이 박힌다. title을 안 넘기므로
NULL → "제목 없음", 메시지가 없으니 daemon이 claude를 돌릴 일도 없어input/output/cache 0. - 이게 핵심 안티패턴이다: GET이 DB를 변이시킨다(safe/idempotent 위반). 그래서 링크 클릭뿐 아니라 브라우저 prefetch/speculative navigation, 봇 크롤, 더블클릭, 뒤로가기 재검증 어느 것이든 빈 세션을 양산한다. 목록에 마구 쌓이는 양상과 정확히 맞는다.
2) daemon이 빈 세션을 자동 생성하나 — 아니오
daemon은 chat_sessions를 전혀 INSERT하지 않는다. 확인한 daemon 경로:
heartbeat()=GET /bridge/heartbeat(last_seen 갱신만, INSERT 없음, daemon.js:1483)- poll/ingest/complete = 이미 존재하는 task/session에 쓰기만 함
- 압축(compaction)은 새 claude CLI 세션 id만 만들고 기존
chat_sessions행을 재사용 — 새 행 INSERT 아님
"[브리지 연결 테스트]" 세션은 daemon이 아니라 bridge 위임 엔드포인트(/bridge/..., src/app/bridge/routes.ts:1148)를 외부에서 호출한 흔적이다. 이 엔드포인트는 세션+메시지+task를 한 번에 만든다. 어떤 테스트 스크립트/에이전트가 이 경로를 두드리면 세션이 생긴다. 정확한 title 문자열의 출처(누가 "[브리지 연결 테스트]"를 넣었는지)는 호출자 코드를 봐야 100% 확정된다 — 여기까진 추측이라 못 박지 않는다. 다만 daemon 발원이 아닌 건 확실하다.
이 외 worker 측 자동 생성기(참고): 자기반성 세션(routes.ts:307, task 완료마다 "[자기반성]"), 스케줄 리포트(schedules.ts:122), brain/draft/reports/a2a/voice. 이들은 title이 있고 메시지/task가 붙는다 — "제목 없음·0토큰" 패턴과는 다르다.
3) 최근 변경 영향 — 빈 세션 양산은 최근 daemon 변경 탓이 아니다
/sessions/new엔드포인트는 신규가 아니다(스냅샷 커밋f37c706부터 존재). 메커니즘은 이전부터 있던 구조적 결함.- 최근 daemon 커밋(
d9fdb96하트비트→activity,13ce02dthrottle 분리,99dca83스킬 시딩 등)은 어느 것도 chat_sessions를 만들지 않는다. - 따라서 "최근에 뭐가 바뀌어서 터졌다"면 daemon이 아니라 링크 노출 증가/프리페치·봇 트래픽 쪽을 의심해야 한다.
4) 진짜 원인과 가장 깔끔한 해결책
진짜 원인: 변이를 일으키는 GET(/chat/sessions/new)이 메시지 한 통 오기도 전에 행을 물질화한다. 안전하지 않은 GET이라 사람 클릭 외의 트래픽에도 반응한다.
권장 — 두 가지를 함께:
| 우선 | 조치 | 효과 | 비용/리스크 |
|---|---|---|---|
| 본질 수정 | 세션을 "입장 시"가 아니라 "첫 메시지 전송 시" 생성. "새 세션"은 DB 행 없는 작성(compose) 화면으로 보내고, 첫 메시지가 실제 전송될 때 INSERT. | 빈 세션 클래스 자체 소멸 | chat 작성 플로우 수정 필요 |
| (동등한 경량안) | 위가 부담이면 최소한 GET→POST 전환(prefetch/봇 차단) + dedup: 해당 에이전트에 "메시지 없는 빈 세션"이 이미 있으면 새로 만들지 말고 그 행으로 redirect | 신규 생성 거의 차단 | 작음 |
| 즉시 완화 | "최근 세션" 목록 쿼리(listChatSessions)에서 메시지 0건 & 0토큰 세션 숨김(pinned 제외) |
이미 쌓인 빈 세션 + 신규가 화면에서 사라짐 | 작음, 단 orphan 행은 DB에 잔존 |
| 보조 | 메시지 없는 N분 경과 세션 주기적 삭제 잡 | 청소 | 작성 중 세션과 레이스 위험 → 단독 의존 금지 |
내 결론: "생성을 막기 + 목록에서 숨기기"를 같이 간다. 본질 수정(첫 메시지 시 생성, 안되면 POST+dedup)으로 발생을 끊고, 목록 필터로 기존 누적분을 즉시 가린다. 정리 잡 단독은 비권장(레이스).
분담 메모
- 수정 위치는 전부 LAX-site worker(
src/app/chat/routes.tsx,src/shared/chat.ts)다 — daemon 변경 불필요. 네가 병렬로 보는 site 코드 트랙이 맞다. - 정확한 라인 핸드오프: 생성부
chat/routes.tsx:852(GET→변이), 목록부shared/chat.ts:57 listChatSessions(여기에 메시지/토큰 필터), dedup 후보 SELECT 기준agent_id + 메시지 0건. - 미확정 1건: "[브리지 연결 테스트]" title의 정확한 호출자 — bridge 엔드포인트(
bridge/routes.ts:1148)를 누가 호출했는지 그 caller를 봐야 확정된다. daemon 발원은 배제 확정.
daemon/런타임은 이 건에서 clean이다. 비가역 변경 없음, 롤백 대상 없음.