Skip to content

fix(sdk): stop Slack/Discord chat daemons from publishing lean finals twice - #6305

Merged
Yeachan-Heo merged 2 commits into
Yeachan-Heo:devfrom
nahyeongjin1:fix/lean-final-answer-publication-dedupe
Oct 4, 2026
Merged

Yeachan-Heo merged 2 commits into
Yeachan-Heo:devfrom
nahyeongjin1:fix/lean-final-answer-publication-dedupe

Conversation

@nahyeongjin1

@nahyeongjin1 nahyeongjin1 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

What

Slack/Discord 채팅 데몬이 lean 모드의 최종 답변을 매번 두 번 게시하던 문제를 고칩니다.

  • SessionRouterOptions에 opt-in 옵션 positionedNotificationEffects를 추가했습니다. 켜면 Router의 기본 SDK 연결이 positioned_notification_effects_v1 capability도 알립니다.
  • 이 옵션은 ChatDaemonRuntime만 켭니다. gjc sdk session tail 같은 일반 SDK observer는 지금처럼 두 공개 경로를 모두 받습니다(fix(sdk): broadcast positioned session events live to attached subscribers #4570).
  • capability 상수의 정의는 router가 Telegram 데몬을 import하지 않도록 sdk/host/host.ts로 옮겼습니다. 공개 경로 @gajae-code/coding-agent/sdk/bus/telegram-daemon에서도 같은 이름으로 계속 export됩니다.

Why

  • host는 각 turn_stream을 두 경로로 보냅니다(sdk/bus/index.ts pushSessionFrame).
    • 순번이 붙은 event envelope
    • native 원시 push. 순번 envelope를 이미 받은 연결 중 positioned_notification_effects_v1을 협상한 연결만 대상에서 제외합니다.
  • 이 capability를 알리는 쪽은 Telegram 데몬뿐이었습니다. Slack/Discord ChatDaemonRuntime이 쓰는 SessionRouter의 기본 연결은 observer/turn_stream capability만 알려서 두 경로를 모두 받았습니다.
  • 순번 경로에는 Router publicationId가 붙고, 원시 경로에는 붙지 않습니다. lean 최종 답변에는 messageRef도 없어서 두 사본을 하나로 묶을 식별자가 없고, 원시 사본이 두 번째 게시가 됐습니다.
  • 실제 사용 기록에서 확인한 내용
    • 로컬 Slack effect journal에서 lean 최종 답변 25건이 21개 세션에 걸쳐 모두 중복 게시됐습니다.
    • 각 쌍의 한쪽은 Router 좌표(<sessionId>:<generation>:<seq>)에서 나온 결정적 clientMsgId였고, 다른 쪽은 publicationId가 없는 경로의 무작위 ID였습니다. 한 쌍은 해시를 역산해 좌표 :1:128까지 확인했습니다.

검토 후 채택하지 않은 대안

  • lean 최종 답변에 messageRef 부여: threaded-render의 같은 메시지 수정(edit-in-place)과 rich 승격 동작이 바뀝니다.
  • 본문 해시로 중복 제거: 의도적으로 같은 내용을 반복한 답변까지 지울 수 있습니다.

Testing

RED → GREEN

  • sdk-chat-daemon-worker.test.ts: "posts a lean final answer from the production host to {Slack,Discord} exactly once"
    • 실제 production host와 실제 ChatDaemonRuntime을 띄워 agent_start → message_end → turn_end → agent_end를 진행합니다.
    • RED는 Expected: 1, Received: 2(Slack, Discord 모두)였고, 수정 후 GREEN입니다.
    • capability 한 줄 추가만으로 RED가 GREEN이 됐습니다.
  • sdk-host-wiring.test.ts: "only a positioned-effects SessionRouter negotiates positioned-only notification delivery"
    • 실제 host를 상대로, 일반 observer: true router는 capability를 알리지 않고 opt-in한 router만 알리는지 검증합니다.
    • capability 줄을 끄면 RED, 되돌리면 GREEN입니다.

관련 테스트 묶음 (모두 exit 0, 실패 0)

  • sdk-chat-daemon-worker 14, chat-daemon-session-reconnect 28, notifications-turn-ordering 29
  • sdk-session-router-attach-scope 4, sdk-session-router-authority 71, sdk-correlated-frame-delivery 1
  • notifications-tool-activity 11, sdk-chat-daemon-control-frames 26, sdk-slack-daemon 103
  • sdk-host-wiring 131, notifications-topic-registry 61, sdk-telegram-fault-containment 6, sdk-daemon-cli-e2e 59
  • 최종 head(3a03f30c6)에서 별도로 다시 돌린 sdk-chat-daemon-worker + sdk-host-wiring + sdk-slack-daemon → 248 pass / 0 fail

기타

  • bun --cwd=packages/coding-agent run check → exit 0 (경고 2·info 1건은 이 변경과 무관한 파일)
  • 변경 파일 bunx biome check → exit 0
  • 옛 공개 경로(sdk/bus/telegram-daemon)와 새 경로(sdk/host/host)에서 import한 상수가 같은 값인지 확인

코드 읽기로만 확인한 동작 (전용 테스트 없음)

  • 순번 경로 전송이 실패하면 host가 그 연결을 제외 대상에 넣지 않으므로, 원시 사본이 정확히 한 번 전달됩니다.
  • capability를 모르는 구 host는 지금처럼 두 사본을 모두 보냅니다(회귀 없음). Telegram 데몬이 이미 모든 host에 이 capability를 보내고 있습니다.
  • verbose/stream 모드의 messageRef 기반 같은 메시지 수정은 그대로입니다.
  • 실제 Slack/Discord 네트워크 전송은 테스트하지 않았습니다.

Daemon generation bump (ca9187525)

SessionRouter.#createAttachedClient는 Slack/Discord 채팅 데몬 lifecycle의 보호 선언이라 telegram-daemon-generation-guard가 generation 상향을 요구했습니다. bun scripts/telegram-daemon-generation-guard.ts --fix-generations 61f2e17243af522e1572caf8d82f8f0e6a696684로 CHAT_DAEMON_GENERATIONS를 discord 82→83, slack 89→90으로 올리고 semantic manifest를 재생성했습니다. 이미 실행 중인 데몬은 교체되어야 새 capability를 협상하므로 의도한 효과입니다. 로컬 guard는 required generation bump verified(exit 0)였고, sdk-chat-daemon-control-frames + sdk-daemon-cli-e2e 85건 pass / 0 fail입니다.

Approval

Merges to dev require one approving GitHub review from a write-access maintainer on the current head. Agent reviews (architect/critic) are advisory comments.

Risk classification: medium / narrow — 알림 데몬의 SDK 연결 capability 협상을 바꿉니다. 일반 SDK observer와 Telegram 경로는 바뀌지 않습니다.


  • Target branch is dev
  • bun check passes (bun --cwd=packages/coding-agent run check)
  • Tested locally
  • Changelog fragment added under packages/<pkg>/changelog.d/ (if user-facing)
  • Human approval or the required agent/owner verdict matches the exact PR head, not an earlier commit
  • Risk classification above matches the actual review path taken

… twice

The host emits each turn frame on a positioned leg and a raw native leg,
excluding from the raw leg only connections that negotiate
positioned_notification_effects_v1. The SessionRouter client used by the
Slack/Discord ChatDaemonRuntime never advertised it, so it received both
copies; a lean final has no messageRef, so the raw copy (no publicationId)
became a second provider post next to the Router-positioned one.

SessionRouter gains an opt-in positionedNotificationEffects option that
ChatDaemonRuntime sets. The capability constant moves to sdk/host/host so
the router does not import from telegram-daemon.

Lore-id: 7c41e2a9
Constraint: plain SDK observers (sdk session tail) must keep both public surfaces (Yeachan-Heo#4570)
Rejected: add messageRef to lean finals | changes threaded-render edit-in-place/rich promotion
Rejected: dedupe by body hash | collapses legitimately repeated answers
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: notification-effect publishers built on SessionRouter must opt into positionedNotificationEffects
Tested: production host -> Slack/Discord ChatDaemonRuntime lean final posts once (RED 2 -> GREEN 1)
Tested: real host negotiation: plain observer router lacks capability, opted-in router advertises it
Tested: chat-daemon worker/reconnect, turn-ordering, session-router, slack-daemon, host-wiring suites
Not-tested: live Slack/Discord network delivery
Co-authored-by: Claude <noreply@anthropic.com>

@probepark probepark left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 (head 3a03f30, gajae-reviewer가 probepark 대신 작성)

CI: PR 원인 1개 — Telegram daemon generation guard 실패 (https://github.com/Yeachan-Heo/gajae-code/actions/runs/37174073883/job/111353012945). gjc-state-gates / native addon, Affected path validation / native-build는 아직 대기 중입니다.
범위: +200 / -7, 9 files — packages/coding-agent/src/sdk/{router,bus,host}, tests 3개, changelog.d fragment
규약: changelog fragment 있음(packages/coding-agent/changelog.d/lean-final-answer-publication-dedupe.md), generated 파일 없음, 라벨 없음

주목할 곳:

  • packages/coding-agent/src/sdk/router/session-router.ts:1546-1552 — SessionRouter.#createAttachedClient 본문이 바뀌었습니다. 이 선언은 scripts/telegram-daemon-generation-manifest.json:436(discord:)와 :521(slack:)에 digest가 고정돼 있습니다. 가드 로그: semantic manifest declaration digests do not byte-match the current tree (discord:…SessionRouter.#createAttachedClient: manifest=76a3018a… actual=f7d9879a…, slack:… 동일). 이 PR 원인임을 확인했습니다: base 61f2e17은 dev tip(behind_by=0)이고, manifest는 diff에 없으며, 가드의 canonical 형식으로 계산한 선언 길이가 base 1765자 → head 1905자로 달라졌습니다.
  • 위 실패는 가드의 1단계(digest)입니다. manifest만 다시 만들면 2단계에서 CHAT_DAEMON_GENERATIONS 상향을 요구합니다. head와 dev 모두 packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:143-146이 discord: 82, slack: 89로 같습니다. 이 변경은 실행 중인 Slack/Discord 데몬의 capability 협상을 바꿉니다. 그래서 상향하지 않으면 업그레이드 후에도 옛 데몬이 유지돼 중복 게시가 계속됩니다. 해결: 로컬에서 bun run fix:daemon-generations 61f2e17243af522e1572caf8d82f8f0e6a696684(digest 재생성과 discord/slack 상향을 한 번에 적용)을 실행하고 결과를 커밋하세요. dev가 그 사이에 움직였으면 새 base sha로 다시 실행하세요.
  • 로직 자체(opt-in positionedNotificationEffects, ChatDaemonRuntime만 켜는 것, 상수를 sdk/host/host.ts로 옮기고 telegram-daemon.ts에서 다시 export하는 것)에서는 결함을 찾지 못했습니다. host/host.ts는 bus/telegram-daemon을 import하지 않으므로 새 순환 import도 없습니다. sdk-host-wiring.test.ts의 spyOn은 finally에서 mockRestore()로 정리됩니다.

blocking: 1번 (generation guard: manifest digest와 discord/slack generation 상향 누락)

Verdict: gajae.pr-review-verdict.v1 needs-human sha256:7a1da23a3aebb5608a24b6614888bc79337110f1f52f5e5cd9c39aa3fbfd6d2b reviewer:critic reviewer-id:gajae-reviewer evidence:generation-guard-red-pr-caused;createAttachedClient-digest-moved;chat-daemon-generations-unbumped

PR body verdict line count=0, not updated. Suggested verdict line: gajae.pr-review-verdict.v1 needs-human sha256:7a1da23a3aebb5608a24b6614888bc79337110f1f52f5e5cd9c39aa3fbfd6d2b reviewer:critic reviewer-id:gajae-reviewer evidence:generation-guard-red-pr-caused;createAttachedClient-digest-moved;chat-daemon-generations-unbumped

…ability change

SessionRouter.#createAttachedClient is a protected chat-daemon lifecycle
declaration. Advertising positioned_notification_effects_v1 from it changes
what a running Slack/Discord daemon negotiates, so already-running owners must
be replaced rather than keep receiving both delivery legs.

Applied with `bun scripts/telegram-daemon-generation-guard.ts --fix-generations
61f2e17` (discord 82->83, slack 89->90) and
the regenerated semantic manifest.

Lore-id: 5e0b7d21
Constraint: protected chat-daemon lifecycle changes require a strictly higher CHAT_DAEMON_GENERATIONS entry
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: telegram-daemon-generation-guard passes against base 61f2e17
Co-authored-by: Codex <noreply@openai.com>
@nahyeongjin1

Copy link
Copy Markdown
Contributor Author

@probepark 3a03f30c6에 대한 리뷰의 blocking 1건(generation guard)을 ca9187525에서 해결했습니다. 같은 head에 대한 재리뷰를 부탁드립니다.

조치 (ca9187525, 별도 커밋이며 force-push 없음)

  • 리뷰에서 제시한 명령을 그대로 실행했습니다: bun scripts/telegram-daemon-generation-guard.ts --fix-generations 61f2e17243af522e1572caf8d82f8f0e6a696684 (= bun run fix:daemon-generations)
    • CHAT_DAEMON_GENERATIONS discord 82→83, slack 89→90 (packages/coding-agent/src/sdk/bus/chat-daemon-control.ts)
    • scripts/telegram-daemon-generation-manifest.json의 SessionRouter.#createAttachedClient(discord/slack) digest와 generation digest 재생성
  • base 61f2e17은 지금도 dev tip입니다(ahead_by=0). 그래서 base sha를 바꿔 다시 실행할 필요는 없었습니다.

증거 (exact head ca9187525)

  • Dev CI run https://github.com/Yeachan-Heo/gajae-code/actions/runs/37176110362 : 36 pass / 13 skipping, 실패 0
    • Telegram daemon generation guard: pass (로컬도 v52 required generation bump verified, exit 0)
    • 이번 generation 변경으로 Windows Telegram daemon safety도 실행되어 pass
  • cd packages/coding-agent && bun run check: exit 0
    • biome는 0 errors입니다. 경고 2·info 1은 이 PR이 건드리지 않는 test/sdk-diagnostics-entry.test.ts, test/sdk-diagnostics-broker-matrix.test.ts에 있습니다.
    • check:types도 clean입니다. 로컬 native 빌드가 생성한 파일 대신 커밋된 packages/natives/native/*로 실행했습니다.
  • bun test packages/coding-agent/test/sdk-chat-daemon-control-frames.test.ts packages/coding-agent/test/sdk-daemon-cli-e2e.test.ts: 85 pass / 0 fail

@probepark probepark left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 (head ca91875, gajae-reviewer가 probepark 대신 작성)

CI: green — Telegram daemon generation guard pass (https://github.com/Yeachan-Heo/gajae-code/actions/runs/37176110362/job/111359046507). check:@gajae-code/coding-agent, test:@gajae-code/coding-agent:shard-1-of-8, sdk-chat-daemon-worker, sdk-host-wiring, notifications-turn-ordering, Windows Telegram daemon safety, Virtual integration validation, gjc-state-gates가 모두 pass입니다. approve 게이트 ALLOW(대기 0, 실패 0).
범위: +206 / -13, 11 files — packages/coding-agent/src/sdk/{router,bus,host}, tests 3개, changelog.d fragment, scripts/telegram-daemon-generation-manifest.json
규약: changelog fragment 있음(packages/coding-agent/changelog.d/lean-final-answer-publication-dedupe.md, ### Fixed), 손편집 generated 파일 없음(manifest는 guard --fix-generations 출력), 라벨 없음

주목할 곳:

  • 이전 blocking(3a03f30 리뷰 5404134013) 해소: 3a03f30..ca91875 diff는 2 files, +6/-6뿐입니다. packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:144-145가 discord: 83, slack: 90으로 올랐고, manifest의 SessionRouter.#createAttachedClient digest(discord/slack)가 가드 로그의 actual 값 f7d9879a…로, CHAT_DAEMON_GENERATIONS.{discord,slack} digest가 갱신됐습니다. 그 밖의 코드는 3a03f30와 같습니다.
  • packages/coding-agent/src/sdk/router/session-router.ts:1546-1552 — capability 배열은 observer와 positionedNotificationEffects를 각각 독립적으로 더합니다. 두 옵션이 모두 꺼져 있으면 예전처럼 capabilities를 넘기지 않습니다. chat-daemon-runtime.ts:321-323만 opt-in하므로 일반 SDK observer(gjc sdk session tail)의 동작은 바뀌지 않습니다.
  • base 61f2e17 이후 dev는 2 commits 앞서 있습니다(config-cli.ts 및 테스트, changelog). 이 PR과 겹치는 파일은 없습니다.

blocking: 없음

Verdict: gajae.pr-review-verdict.v1 merge-approved sha256:3d6fe80401ec3fcb71b5a1f762509ad44c1aae04e2a7b32156aa3d09db146078 reviewer:human reviewer-id:probepark evidence:ci-green;generation-guard-green;daemon-generations-bumped;manifest-regenerated;capability-opt-in-scoped

PR body verdict line count=0, not updated. Suggested verdict line: gajae.pr-review-verdict.v1 merge-approved sha256:3d6fe80401ec3fcb71b5a1f762509ad44c1aae04e2a7b32156aa3d09db146078 reviewer:human reviewer-id:probepark evidence:ci-green;generation-guard-green;daemon-generations-bumped;manifest-regenerated;capability-opt-in-scoped

@Yeachan-Heo
Yeachan-Heo merged commit 73475e1 into Yeachan-Heo:dev Oct 4, 2026
61 checks passed
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Merged into dev as 73475e18.

Thanks @nahyeongjin1 — two good fixes in one morning.

—
[repo owner's gaebal-gajae (clawdbot) 🦞]

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants