공통 인시던트 컨텍스트
2026-09-11 20:10:29 KST, Slack 채널 C0AKXLE61J5(#ceo_lead_align_input)의 스레드 1789104278.881179 에서 사용자가 봇을 멘션했다. 봇은 2초 뒤 "경과 531초 / 8분째 실행 중" 진행 카드를 올렸고(=이미 돌던 다른 런의 진행 상태), 약 3분 뒤 전혀 무관한 ReelBrain 티켓(exp3-cardnews-reunion-conditions, qa-delivery BLOCKED)의 ## Terminal phase summary 본문을 그 스레드에 게시했다. 게시된 두 메시지는 이후 수동 삭제했다.
로그: ~/.cli-jaw/logs/serve.log 19555-19670, jaw-serve.err. DB: ~/.cli-jaw/jaw.db messages 1666/1676/1677.
라벨 제안: 본문 상단 표기 참조.
제안 라벨: enhancement priority:P1 area:messaging
하트비트 목적지를 스레드-채널 쌍으로 하드 바인딩하고, 불완전·불일치 시 전송을 거부한다
요약
destination은 {channel, targetId, threadId?}라 스레드가 선택이다. 없으면 last-active로 다른 채널에 새고, 스레드가 그 채널 소속인지도 안 본다. 지정 스레드의 채널에만 올리려면 쌍을 필수화하고, 비거나 stale하면 보내면 안 된다.
재현·증거
src/core/config.ts:1427-1504: dest 부재 시 "active channel last-active, 안정 목적지 아님 (#437)". threadId?: string. isHeartbeatDestination은 transport + 비지 않은 targetId만 보고, threadId === ""도 통과.
src/memory/heartbeat.ts:80-98, 551-570: dest 없음 → pinned:false → sendChannelOutput({channel:'active'}). 형식 오류 → 미전송. dest만 있고 thread 없음 → 채널 루트. 소속 조회 없음.
src/messaging/send.ts:120-126, 450-487: allowActiveFallback !== false이면 lastActive → latestSeen → configured. #437과 동일.
heartbeat 턴은 grant 없이 chatSessionId:'default'. POST /api/channel/send 고정(src/routes/messaging.ts:175-187)은 kind==='turn'만. localhost operator는 다른 채널로 직접 보낸다. 라이브 프롬프트 다수가 그 API를 부르라고 적혀 있다. mention-watch는 job dest가 아니라 hit channelId/threadTs로 보낸다 (heartbeat.ts:365-379).
라이브 ~/.cli-jaw/heartbeat.json 전수 9건
- 채널+스레드 완전 3:
hb_ax_friday_kr, hb_slack_daily_digest, hb_gambok_push_result_2010
- dest 있으나 thread 없음 5:
hb_ad_morning_brief, hb_hellobot_sub_daily_todo, hb_reelbrain_daily, hb_suji_scrum_1800, hb_daily_scrum_morning
- dest 없음 1:
hb_suji_mention_watch
- 하드바인딩 불완전 6. enabled이면서 불완전 1 (
hb_daily_scrum_morning, C08VC7AMVUL만). 형식 오류 0. 스레드 소속 라이브 조회는 안 함.
원인
#437은 "dest 있으면 거기로, 없으면 레거시 last-active"만 잠갔다 (tests/unit/heartbeat-runner-modes.test.ts:161-169). 스레드 강제·소속 검증·에이전트 send 핀이 없다. 가설: enabled daily scrum이 침묵 실패하면 last-active 스레드로 본문이 갈 수 있다.
수정 제안
- Slack dest는
channel+targetId+threadId 필수. 채널 루트는 scope:"channel_root"를 명시. PUT resolveHeartbeatDestination도 동일 거부 (src/core/config.ts, src/routes/heartbeat.ts).
assertHeartbeatThreadBinding()를 src/memory/heartbeat-destination.ts에 두고 tick/PUT/load에서 호출. 정적 쌍 검사 + tick 시 conversations.replies(channel, ts). 실패·불일치·조회 실패는 fail-closed.
heartbeatTarget의 pinned:false 삭제. dest 불완전하면 sendChannelOutput 호출 금지.
- heartbeat origin에 job dest로
reserveSlackToolGrant. 다른 채널은 slack_destination_mismatch. grant 없으면 Slack send 금지.
- 불완전 job은
held: incomplete_destination. last-active를 채워 넣지 말 것.
- 로그:
[heartbeat:<name>] refuse: unbound_destination|thread_channel_mismatch|stale_thread job=... channel=... thread=... (토큰 금지).
대안: 채널 루트는 채널만 고정하고 last-active 금지. mention-watch는 hit가 watch.channelIds 부분집합일 때만. 당일 리마인더 ts 바인더가 실패하면 보내지 않음.
영향
runHeartbeatJob, mention-watch, 에이전트 send, PUT/load. "no dest → active" 테스트 폐기. 라이브 6건 hold. enabled 1건은 당일 ts 없으면 기동 거부.
검증
빈 dest / thread 없음 / 불일치 / stale → send 0, fallback 0. 완전 쌍 → 그 target+thread만. 다른 채널 send → mismatch. 픽스처로 9건 분류(완전 3 / 불완전 6) 고정. 이 이슈에서 라이브 슬랙 게시는 하지 않음.
관련 이슈 후보
#437. heartbeat origin 미그랜트 send. 동적 리마인더 ts 바인딩. mention-watch hit vs job dest.
같은 인시던트에서 갈라진 이슈: #742 · #743 · #744
제안 라벨:
enhancementpriority:P1area:messaging하트비트 목적지를 스레드-채널 쌍으로 하드 바인딩하고, 불완전·불일치 시 전송을 거부한다
요약
destination은{channel, targetId, threadId?}라 스레드가 선택이다. 없으면 last-active로 다른 채널에 새고, 스레드가 그 채널 소속인지도 안 본다. 지정 스레드의 채널에만 올리려면 쌍을 필수화하고, 비거나 stale하면 보내면 안 된다.재현·증거
src/core/config.ts:1427-1504: dest 부재 시 "active channel last-active, 안정 목적지 아님 (#437)".threadId?: string.isHeartbeatDestination은 transport + 비지 않은targetId만 보고,threadId === ""도 통과.src/memory/heartbeat.ts:80-98, 551-570: dest 없음 →pinned:false→sendChannelOutput({channel:'active'}). 형식 오류 → 미전송. dest만 있고 thread 없음 → 채널 루트. 소속 조회 없음.src/messaging/send.ts:120-126, 450-487:allowActiveFallback !== false이면 lastActive → latestSeen → configured. #437과 동일.heartbeat 턴은 grant 없이
chatSessionId:'default'.POST /api/channel/send고정(src/routes/messaging.ts:175-187)은kind==='turn'만. localhost operator는 다른 채널로 직접 보낸다. 라이브 프롬프트 다수가 그 API를 부르라고 적혀 있다. mention-watch는 job dest가 아니라 hitchannelId/threadTs로 보낸다 (heartbeat.ts:365-379).라이브
~/.cli-jaw/heartbeat.json전수 9건hb_ax_friday_kr,hb_slack_daily_digest,hb_gambok_push_result_2010hb_ad_morning_brief,hb_hellobot_sub_daily_todo,hb_reelbrain_daily,hb_suji_scrum_1800,hb_daily_scrum_morninghb_suji_mention_watchhb_daily_scrum_morning,C08VC7AMVUL만). 형식 오류 0. 스레드 소속 라이브 조회는 안 함.원인
#437은 "dest 있으면 거기로, 없으면 레거시 last-active"만 잠갔다 (
tests/unit/heartbeat-runner-modes.test.ts:161-169). 스레드 강제·소속 검증·에이전트 send 핀이 없다. 가설: enabled daily scrum이 침묵 실패하면 last-active 스레드로 본문이 갈 수 있다.수정 제안
channel+targetId+threadId필수. 채널 루트는scope:"channel_root"를 명시. PUTresolveHeartbeatDestination도 동일 거부 (src/core/config.ts,src/routes/heartbeat.ts).assertHeartbeatThreadBinding()를src/memory/heartbeat-destination.ts에 두고 tick/PUT/load에서 호출. 정적 쌍 검사 + tick 시conversations.replies(channel, ts). 실패·불일치·조회 실패는 fail-closed.heartbeatTarget의pinned:false삭제. dest 불완전하면sendChannelOutput호출 금지.reserveSlackToolGrant. 다른 채널은slack_destination_mismatch. grant 없으면 Slack send 금지.held: incomplete_destination. last-active를 채워 넣지 말 것.[heartbeat:<name>] refuse: unbound_destination|thread_channel_mismatch|stale_thread job=... channel=... thread=...(토큰 금지).대안: 채널 루트는 채널만 고정하고 last-active 금지. mention-watch는 hit가
watch.channelIds부분집합일 때만. 당일 리마인더 ts 바인더가 실패하면 보내지 않음.영향
runHeartbeatJob, mention-watch, 에이전트 send, PUT/load. "no dest → active" 테스트 폐기. 라이브 6건 hold. enabled 1건은 당일 ts 없으면 기동 거부.검증
빈 dest / thread 없음 / 불일치 / stale → send 0, fallback 0. 완전 쌍 → 그 target+thread만. 다른 채널 send → mismatch. 픽스처로 9건 분류(완전 3 / 불완전 6) 고정. 이 이슈에서 라이브 슬랙 게시는 하지 않음.
관련 이슈 후보
#437. heartbeat origin 미그랜트 send. 동적 리마인더 ts 바인딩. mention-watch hit vs job dest.
같은 인시던트에서 갈라진 이슈: #742 · #743 · #744