Skip to content

[heartbeat] 목적지를 스레드-채널 쌍으로 하드 바인딩하고 불완전·불일치 시 전송을 거부한다 #745

Description

@cosmosjeon

공통 인시던트 컨텍스트
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:falsesendChannelOutput({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 스레드로 본문이 갈 수 있다.

수정 제안

  1. Slack dest는 channel+targetId+threadId 필수. 채널 루트는 scope:"channel_root"를 명시. PUT resolveHeartbeatDestination도 동일 거부 (src/core/config.ts, src/routes/heartbeat.ts).
  2. assertHeartbeatThreadBinding()src/memory/heartbeat-destination.ts에 두고 tick/PUT/load에서 호출. 정적 쌍 검사 + tick 시 conversations.replies(channel, ts). 실패·불일치·조회 실패는 fail-closed.
  3. heartbeatTargetpinned:false 삭제. dest 불완전하면 sendChannelOutput 호출 금지.
  4. heartbeat origin에 job dest로 reserveSlackToolGrant. 다른 채널은 slack_destination_mismatch. grant 없으면 Slack send 금지.
  5. 불완전 job은 held: incomplete_destination. last-active를 채워 넣지 말 것.
  6. 로그: [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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:messagingCross-channel messaging runtime and adaptersenhancementNew feature or requestpriority:P1Next up after stabilization

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions