Skip to content

fix(sdk): retire a running Slack/Discord chat daemon once its notifications are disabled - #6312

Merged
Yeachan-Heo merged 3 commits into
Yeachan-Heo:devfrom
nahyeongjin1:fix/chat-daemon-retire-when-disabled
Oct 4, 2026
Merged

Yeachan-Heo merged 3 commits into
Yeachan-Heo:devfrom
nahyeongjin1:fix/chat-daemon-retire-when-disabled

Conversation

@nahyeongjin1

@nahyeongjin1 nahyeongjin1 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

What

알림을 꺼도 이미 실행 중인 Slack/Discord 채팅 데몬이 계속 살아서 Slack에 게시하던 문제를 고칩니다.

  • 실행 중인 데몬이 기존 5초 heartbeat 주기마다 loadConfig()로 설정을 다시 읽습니다(packages/coding-agent/src/sdk/bus/chat-daemon-cli.ts).
  • 설정이 이 provider를 확실히 끈 결과(notifications.enabled: false 또는 provider의 enabled: false)가 두 번 연속 나오면 스스로 종료합니다. 종료 경로는 운영자 gjc daemon stop과 같습니다: runtime 정지 → serve loop 종료 → finally에서 stoppedAt과 함께 소유권 해제.
  • 읽기·검증 오류(반쯤 쓰인 파일, 불완전하거나 격리된 provider 설정)는 판단할 수 없는 상태로 보고 계속 동작하며, 연속 카운트도 초기화합니다.
  • 새 파일 watcher는 추가하지 않았습니다. identity 변경은 이미 ensure()가 교체하므로 종료 조건에 넣지 않았습니다.
  • 보호 대상인 데몬 lifecycle 선언이 바뀌어 CHAT_DAEMON_GENERATIONS를 discord 83→84, slack 90→91로 올리고 semantic manifest를 재생성했습니다(fe53f17a0).

Why

실제 설치 환경에서 관찰한 현상입니다.

  • notifications.slack.enabled: false, notifications.enabled: false로 바꾼 뒤에도 기존 Slack 데몬(gjc daemon slack-internal)이 health: running과 갱신되는 heartbeat로 계속 실행됐습니다.
  • 약 15시간 동안 GJC session ready. / Session closed. 20건을 채널에 게시했고, 수동 gjc daemon stop slack 후에야 멈췄습니다.

원인

  • runChatDaemonInternal은 소유권을 얻기 전에 설정을 한 번만 읽고 다시 읽지 않습니다. 5초 주기는 heartbeat와 소유권만 확인합니다.
  • ChatDaemonController.ensure()는 provider가 꺼져 있으면 "disabled"를 바로 반환하고 실행 중인 owner는 건드리지 않습니다.
  • 그래서 사용자가 끈 뒤에 실행 중인 데몬을 내릴 경로가 없었습니다.

두 번 연속 읽기를 요구하는 이유

  • notifications.enabled의 기본값이 false입니다. 그래서 원자적이지 않은 재작성 중에 잘린 파일이 유효한 YAML로 읽히면 꺼진 것처럼 보일 수 있습니다.
  • 5초 주기 기준으로 약 10초 안에 종료됩니다.

Telegram 데몬은 이미 owner watchdog(telegram-daemon-cli.ts ~L533–554)에서 설정을 다시 읽고 꺼짐·불완전·자격 증명 변경 시 종료하므로 이 PR에서 바꾸지 않았습니다.

Testing

RED → GREEN — packages/coding-agent/test/daemon-control.test.ts의 runChatDaemonInternal retires when notifications are disabled (5건)

  • Slack/Discord × (provider 꺼짐 / 전역 알림 꺼짐) 4건
    • runtime이 한 번 정지하는지, stoppedAt이 기록되는지, 상태가 stopped인지 검증
    • 다시 켠 뒤 새 owner가 소유권을 얻어 동작하는지 검증
  • 읽기 오류와 단발성 꺼짐 결과가 섞여도 계속 동작하고, 두 번 연속 꺼짐이어야 종료하는지 검증 1건
  • 수정 전 소스: 0 pass / 5 fail (exit 1) → 수정 후: 5 pass / 0 fail

관련 테스트 묶음과 체크

  • daemon-control, sdk-daemon-cli-e2e, sdk-chat-daemon-control-frames, notifications-compiled-daemon-smoke → 286 pass / 0 fail
  • 최종 head에서 daemon-control.test.ts를 별도로 다시 실행 → 194 pass / 0 fail
  • cd packages/coding-agent && bun run check → exit 0 (경고는 이 PR이 건드리지 않는 test/sdk-diagnostics-*의 기존 항목)
  • 변경 파일 bunx biome check → exit 0
  • telegram-daemon-generation-guard PR 모드(base 0e761bcc2, rebase 후 재실행) → v52 required generation bump verified

잔여 위험

  • provider를 끈 뒤 최대 약 10초 동안은 lifecycle 메시지가 게시될 수 있습니다.
  • 설정이 계속 깨진 상태면 데몬은 계속 동작합니다. 의도한 fail-safe이지만, 오류 시 종료하는 Telegram과는 반대 동작입니다.
  • operate("reload")의 allowDisabledNoop 경로는 owner가 아직 실행 중이어도 leaving daemon stopped라고 응답합니다. 이제 owner가 약 10초 안에 스스로 종료하므로 이 PR에서는 그대로 두었습니다.
  • 실제 Slack/Discord 연결 종료는 기존 runtime.stop()을 거치며, 실제 연결로는 테스트하지 않았습니다.

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 — 채팅 데몬 lifecycle의 주기 작업에 종료 조건을 추가하고 generation을 올립니다. Telegram 데몬과 ensure()는 바뀌지 않습니다.


  • Target branch is dev
  • bun check passes (cd packages/coding-agent && bun 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

@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.

Review (head 61bdaf7, gajae-reviewer on behalf of probepark)

CI: PR 원인 1개 — Affected path validation / plan이 Exact-head CI requires this PR head to contain base 0e761bcc27b39eaeb21e4fcecd48b7cc8e764a6c; rebase onto current dev.로 실패했습니다 (https://github.com/Yeachan-Heo/gajae-code/actions/runs/37184961902/job/111384864871). head의 merge-base는 a808f53이고 dev는 이미 0e761bc입니다(#6298, #6293 머지). plan에서 멈췄기 때문에 coding-agent 샤드, Telegram daemon generation guard, Windows Telegram daemon safety는 모두 skipping입니다. 이 head에서는 실행되지 않았으니 통과로 보지 않습니다.
범위: +229 / -7, 5 files — packages/coding-agent/src/sdk/bus (chat-daemon-cli.ts, chat-daemon-control.ts), test (daemon-control.test.ts), changelog.d fragment, scripts/telegram-daemon-generation-manifest.json
규약: changelog.d fragment 있음 (chat-daemon-retire-when-disabled.md), released section 편집 없음, 생성 파일 없음, 라벨 없음, generation bump (discord 83→84, slack 90→91)와 manifest digest 동시 갱신
주목할 곳:

  • chat-daemon-cli.ts configDisablesProvider: loadConfig가 undefined를 돌려주는 경우는 셋입니다. notifications.enabled false, provider desiredEnabled false, 그리고 loaded.status === "not-found"(config.yml 삭제)입니다. 그래서 config 파일을 지우는 경우도 두 번 연속 읽으면 retire로 이어집니다. 의도한 동작이라면 changelog와 주석에 "파일 없음"도 retire 조건이라고 한 줄 추가하세요. read/validation error(throw)를 계속 serve로 처리하는 경로는 catch { return false; }로 맞게 닫혀 있습니다.
  • retireIfDisabled: stopping = true; await stopRuntime(); 이후 serve loop의 while (!stopping)이 빠져나오고 finally의 releaseChatDaemonOwnership이 stopped로 기록합니다. operator stop과 같은 경로이고 stopRuntime은 ??=로 한 번만 호출되므로, interval tick이 겹쳐도 transport stop이 두 번 일어나지 않습니다. 테스트 expect(harness.stops()).toBe(1)이 이 동작을 확인합니다.
  • (note) 새 테스트의 setInterval stub이 as unknown as typeof setInterval과 1 as unknown as ReturnType<...> 이중 캐스트를 씁니다. deps.setInterval 타입을 좁히거나 typed helper로 빼면 캐스트를 없앨 수 있습니다. 테스트 전용이라 blocking은 아닙니다.

blocking: 1개. stale base라 이 head에서 exact-head CI(plan과 하위 샤드 전체, generation guard 포함)가 평가되지 않았습니다. 3단계 코드 리뷰에서 코드 결함으로 볼 blocking 후보는 없었습니다. git rebase origin/dev(dev의 새 커밋은 TUI/session 파일만 건드려 이 PR 파일과 겹치지 않습니다)로 rebase한 뒤 새 head에서 CI가 green이면 다시 판정합니다. rebase 후 generation guard가 새 base 기준으로도 required generation bump verified를 내는지 확인하세요.

PR body verdict line count=0, not updated. Suggested verdict line: gajae.pr-review-verdict.v1 needs-human sha256:e384b6602792377f891a0a51cd97e9d23cecb14c40ee1a97aaf8f60965faf9ca reviewer:critic reviewer-id:gajae-reviewer evidence:stale-base-plan-failed;ci-unevaluated;code-read-no-defect

Verdict: gajae.pr-review-verdict.v1 needs-human sha256:e384b6602792377f891a0a51cd97e9d23cecb14c40ee1a97aaf8f60965faf9ca reviewer:critic reviewer-id:gajae-reviewer evidence:stale-base-plan-failed;ci-unevaluated;code-read-no-defect

nahyeongjin1 and others added 2 commits October 4, 2026 16:27
…ations are disabled

The chat daemon owner read its notification config once at startup and never
again, and ensure() returns "disabled" without touching a live owner, so a
daemon kept posting lifecycle messages after the user turned Slack/Discord
off until a manual `gjc daemon stop`. The owner now re-evaluates the config
on its existing 5 s heartbeat tick and, after two consecutive definite
disabled reads, stops its transport and exits through the same orderly path
as an operator stop, releasing ownership as stopped.

Lore-id: 7c3e9a51
Constraint: read/validation errors (half-written, incomplete, quarantined config) must keep the owner serving
Constraint: no new file watchers; piggyback the existing heartbeat interval
Rejected: retire on the first disabled read | a truncated-but-valid YAML rewrite defaults notifications.enabled to false
Rejected: retire on identity change | ensure() already replaces mismatched owners; out of scope
Rejected: change operate("reload") allowDisabledNoop message | owner now self-retires within ~10 s, making it eventually accurate
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: keep config errors non-retiring; only a definite loadConfig() === undefined counts
Tested: daemon-control retirement suite (slack/discord x provider/global disabled, successor reacquire, transient errors); sdk-daemon-cli-e2e; sdk-chat-daemon-control-frames; notifications-compiled-daemon-smoke; coding-agent check
Not-tested: real Slack/Discord transport shutdown on a live install
Co-authored-by: Claude <noreply@anthropic.com>
The chat daemon owner loop changed its lifecycle behavior, which the
generation guard protects; bump discord 83->84 and slack 90->91 so an
already-running pre-fix owner is replaced instead of attached to.

Lore-id: 4b8d2f60
Constraint: generated via telegram-daemon-generation-guard --fix-generations against merge-base a808f53
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: generation guard in pull_request mode
Co-authored-by: Claude <noreply@anthropic.com>
@nahyeongjin1
nahyeongjin1 force-pushed the fix/chat-daemon-retire-when-disabled branch from 61bdaf7 to fe53f17 Compare October 4, 2026 07:27
…t daemon

configDisablesProvider treats every loadConfig() undefined result as a
definite disable, which includes a missing config file. Review asked for
that condition to be stated alongside the explicit enabled:false cases; it
matches the conditions under which a new owner would not start.

Lore-id: 2f6a91c4
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: telegram-daemon-generation-guard (pull_request mode) and daemon-control suite unchanged
Co-authored-by: Codex <noreply@openai.com>
@nahyeongjin1

Copy link
Copy Markdown
Contributor Author

@probepark 61bdaf7에 대한 리뷰 항목을 아래와 같이 처리했습니다. 현재 head d5e3f1e6e에 대한 재리뷰를 부탁드립니다.

blocking: 옛 base 때문에 exact-head CI가 평가되지 않은 문제 → 해결

  • 0e761bcc2로 rebase했습니다(fe53f17a0). 충돌은 없었습니다. rebase 전후의 변경 내용은 index 줄을 빼면 같습니다.
  • 새 head d5e3f1e6e의 Dev CI는 https://github.com/Yeachan-Heo/gajae-code/actions/runs/37186545338 에서 success입니다(34 pass / 6 skipping, 실패 0). 그중 이번 변경과 직접 관련된 job은 다음과 같습니다.
    • Affected path validation / check:@gajae-code/coding-agent: pass
    • test:packages/coding-agent/test/daemon-control.test.ts: pass
    • Telegram daemon generation guard: pass. 로컬에서도 base 0e761bcc2 PR 모드로 돌려 v52 required generation bump verified가 나왔습니다.
    • Windows Telegram daemon safety: pass

설정 파일 삭제도 종료 조건이라는 점을 명시 → 반영 (d5e3f1e6e)

  • 의도한 동작이 맞습니다. loadConfig()가 undefined를 돌려주는 세 경우는 새 owner가 시작되지 않는 조건과 같습니다: notifications.enabled: false, provider 꺼짐, 설정 파일 없음.
  • configDisablesProvider 주석과 changelog fragment에 "notification config file removed"를 추가했습니다. 동작은 바뀌지 않습니다.

테스트 setInterval stub의 이중 캐스트 (note) → 이번 PR에서는 유지

  • 없애려면 RunChatDaemonInternalDeps.setInterval의 공개 타입(typeof setInterval)을 좁히거나 바꿔야 합니다. 테스트 전용 캐스트를 없애려고 deps 계약을 바꾸는 건 이번 수정 범위 밖이라고 판단했습니다.

exact head d5e3f1e6e 로컬 확인

  • cd packages/coding-agent && bun run check: exit 0. biome 0 errors이고, 경고 2·info 1은 이 PR이 건드리지 않는 test/sdk-diagnostics-*에 있습니다.
  • bun test packages/coding-agent/test/daemon-control.test.ts: 194 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.

Review (head d5e3f1e, gajae-reviewer on behalf of probepark)

CI: green — 이전 판정(61bdaf7)의 blocking이던 stale base가 해소됐습니다. 이 head는 base 0e761bc 위에 있고, Affected path validation(plan, check:@gajae-code/coding-agent, test:@gajae-code/coding-agent:shard-1-of-8, test:packages/coding-agent/test/daemon-control.test.ts 포함), Telegram daemon generation guard, Windows Telegram daemon safety, Virtual integration validation, gjc-state-gates가 모두 pass입니다 (https://github.com/Yeachan-Heo/gajae-code/actions/runs/37186545338). 나머지는 경로 무관 skipping입니다.
범위: +231 / -7, 5 files — packages/coding-agent/src/sdk/bus (chat-daemon-cli.ts, chat-daemon-control.ts), test (daemon-control.test.ts), changelog.d fragment, scripts/telegram-daemon-generation-manifest.json
규약: changelog.d fragment 있음, released section 편집 없음, 생성 파일 손편집 없음, 라벨 없음. generation bump(discord 83→84, slack 90→91)와 manifest digest가 함께 갱신됐고 generation guard가 이 head에서 pass입니다.
주목할 곳:

  • range-diff: rebase로 311dd74/fe53f17의 내용은 68e675d/61bdaf7과 같습니다(=). 새 커밋은 d5e3f1e 하나이고, configDisablesProvider 주석과 changelog fragment에 "config 파일 없음도 retire 조건"을 추가했습니다. 이전 리뷰의 첫 지적이 반영됐습니다. 코드 변경은 없습니다.
  • chat-daemon-cli.ts retireIfDisabled: renewHeartbeat()가 성공한 tick에서만 호출되고, ++disabledReads < 2 || stopping이면 반환합니다. 그다음 stopping = true; await stopRuntime()가 operator stop과 같은 경로로 serve loop를 빠져나가 finally에서 ownership을 해제합니다. stopRuntime의 ??= 때문에 tick이 겹쳐도 transport stop은 한 번만 일어납니다. configDisablesProvider의 catch { return false; }는 read/validation error를 serve로 보내면서 카운트를 0으로 되돌립니다.
  • (note) interval 콜백이 void (async…)에서 return (async…)로 바뀌었습니다. Node setInterval은 반환값을 무시하므로 동작은 같습니다. 내부 try/catch가 모든 경로를 받으므로 unhandled rejection도 생기지 않습니다.

blocking: 없음. approve gate는 ALLOW(pending 0, failed 0, need_local 0, covered check:@gajae-code/coding-agent)입니다.

PR body verdict line count=0, not updated. Suggested verdict line: gajae.pr-review-verdict.v1 merge-approved sha256:2fd4c198f63c5f2fcfddd5442ab495e73127da3782c99740cf1eeaeea2ab64d2 reviewer:human reviewer-id:probepark evidence:ci-green;rebased-onto-dev;range-diff-docs-only;generation-guard-pass;retire-path-read

Verdict: gajae.pr-review-verdict.v1 merge-approved sha256:2fd4c198f63c5f2fcfddd5442ab495e73127da3782c99740cf1eeaeea2ab64d2 reviewer:human reviewer-id:probepark evidence:ci-green;rebased-onto-dev;range-diff-docs-only;generation-guard-pass;retire-path-read

@Yeachan-Heo
Yeachan-Heo merged commit e0b164c into Yeachan-Heo:dev Oct 4, 2026
40 checks passed
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Merged into dev as e0b164c.

  • probepark approved the exact head d5e3f1e. Their earlier change request was on an older head, 61bdaf7.
  • CI on that head passed: 34 checks succeeded and none failed. That includes check:@gajae-code/coding-agent and daemon-control.test.ts.
  • The branch was 4 commits behind dev, and none of those commits touch this PR's 5 files.

Thanks @nahyeongjin1.
—
[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