通知ガバナンス
すべてのプッシュ通知にレベルを付け、クワイエットアワーで急ぎでないものを保留し、1日1通のダイジェストにまとめ、通知タイプごとに「実際に反応されているか」を計測する。
問題:すべての通知が同じ経路で届く
Section titled “問題:すべての通知が同じ経路で届く”DuDuClawのAI社員は自分からあなたに連絡してきます:タスクが行き詰まった、予算を使い切った、高リスクな操作に承認が必要、進化ループが停滞した、チャネルからメッセージを送れない。どのメッセージも単体では妥当ですが、v1.54以前は共通の問題を抱えていました——レベル分けがないことです。深夜3時の「進化イベント」と深夜3時の「タスクが判断待ちで停止中」が同じ経路でプッシュされ、同じように画面を光らせ、同じように音を鳴らしていました。
通知ガバナンスは、すべてのプッシュ出口の手前に立つレイヤーです。役割は4つ:各通知にレベルを付ける、クワイエットアワー中は急ぎでないものを保留する、1日1通のダイジェストにまとめる、そして「このタイプの通知は実際に反応されているのか」を計測することです。
すべての通知は、生成から配信(または保留)までこの経路をたどります:
通知の生成(NotifyLevel は必須、デフォルト値なし) | v L3 か? --はい--> 即時配信(クワイエットアワーは L3 を止めない) | いいえ | v クワイエットアワー中? --いいえ--> 即時配信 | はい | v ~/.duduclaw/notify_queue.jsonl に書き込み(ファイルロック付き) | v 時間帯終了後、バックグラウンドスケジューラが配信: - 同じ宛先の L1 は 1 通にマージ - L2 の決定カードは個別配信 - 上限:最大 500 件、36 時間超は破棄(warn ログを記録)実装構成:プッシュの入口はひとつ
Section titled “実装構成:プッシュの入口はひとつ”判断カードは4種類あります——needs_human で止まったゴール、保留中の承認、インストールの稟議、自動化ルールのサーキットブレーカー作動。それぞれ専用のモジュールが文面と宛先を決めます。そこから先の手順は、すべて notify_push::push(card, dest) という1つの関数に集約されました:
goal_notify ─┐approval_notify ┤install_notify ├──> notify_push::push(card, dest)autopilot_notify┘ | v dest に応じてチャネルの bot token を解決 | v decision_notify::deliver_outcome(レベルとクワイエットアワー、前節参照) | v channel_sender::send_with_markup / send_plain_textdest がどちらのトークン方式を使うかを決めます。ここが、以前は4つの実装それぞれで取り違えられていた箇所です。カードの宛先が AI社員 なら、その社員自身の [channels.<ch>] トークンを使い、見つからなければ reports_to を遡って継承します。宛先が ダッシュボードの人間ユーザー なら、デプロイ側のDM用botトークンを順に試し、その人に実際に届く1本を見つけます。
共通の入口にあえて移さなかったものが2つあります。カードの文面(適切な書き方を知っているのは各ドメインだけです)と、誰がボタンを押せるか(decision_notify::authorize_press と各モジュール自身の配信記録)です。プッシュは外部への副作用であり、誰が判断できるかを広げられる余地があってはなりません。
4段階のエスカレーションラダー
Section titled “4段階のエスカレーションラダー”すべてのプッシュポイントはコード内でレベル(NotifyLevel)を明示しなければならず、デフォルト値はありません——新しいプッシュ出口を追加するたびに「これは人を起こす価値があるか」という問いに答えることを強制されます。
| レベル | 判定基準 | クワイエットアワー中の挙動 | DuDuClawでの例 |
|---|---|---|---|
| L0 | 状態変化なし | そもそもこのレイヤーに入らない | ハートビート、異常なしのパトロール |
| L1 周知 | 起きたが、人は不要 | 保留し、時間帯終了後にマージ | 進化イベント、SOUL統合、スキルギャップ要約、デイリーダイジェスト、予算回復 |
| L2 要確認 | 人が知って1回クリックすべき | 保留し、時間帯終了後に個別配信 | ディスパッチ承認(タスク未開始)、自動化ルールのサーキットブレーカー停止 |
| L3 要対応 | 緊急かつ重要かつ行動可能かつ本物 | そのまま配信、止めない | 自律タスクが判断待ち、高リスク承認、インストール承認、予算による稼働停止、チャネル障害 |
判定基準はGoogle SREのアラート3分類(Page / Ticket / Report)を借用しています。実務上の境界線は「朝まで待つコストがあるか」:ディスパッチ承認が朝まで待っても開始が8時間遅れるだけですが、タスクが途中で判断待ちのまま止まっていれば、自律ループ全体が停止します。
L3は決してクワイエットアワーの影響を受けません。これは意図的です——設定でオフにできる緊急通知は、緊急通知とは呼べないからです。
クワイエットアワー
Section titled “クワイエットアワー”# ~/.duduclaw/agents/<agent>/agent.toml[proactive]quiet_hours = "22:00-08:00" # 任意;未設定 = 無効timezone = "Asia/Taipei" # 任意;パース不能時はホストのシステムタイムゾーン# ~/.duduclaw/config.toml — グローバルフォールバック[notify]quiet_hours = "22:00-08:00"AI社員自身の設定が優先され、なければグローバル値が使われます。深夜をまたぐ区間(22:00-08:00)は正常に動作します。区間は右半開なので、終了を08:00に設定すると08:00ちょうどに配信が再開されます。
パース失敗は常に「無効」扱い
Section titled “パース失敗は常に「無効」扱い”不正なフォーマット、空文字列、start == end(例:00:00-00:00)はすべて「クワイエットアワーなし」と見なされ、どの値が無視されたかをwarnログに記録します。理由は単純です:パーサーのバグが、あるデプロイメントを一晩中通知が届かない状態にしてはならないからです。設定ミスの代償は「通知が通常どおり届く」ことであり、「静かに何も届かなくなる」ことではありません。
レガシーフィールドは引き継がない
Section titled “レガシーフィールドは引き継がない”[proactive]の下には古いquiet_hours_start / quiet_hours_end(数値の時刻)のペアが残っており、そのデフォルトは23–8で、しかもすべてのAI社員が持っています。ガバナンスレイヤーは意図的にこれを読みません——読んでしまうと、既存のすべてのインストールが、誰も望んでいないのに突然一晩中ミュートされてしまうからです。このペアは本来の狭い役割を維持します:[proactive]のプロアクティブチェックの実行時間帯のスケジューリングです。
保留であって破棄ではない
Section titled “保留であって破棄ではない”ブロックされた通知は~/.duduclaw/notify_queue.jsonlに書き込まれ(ファイルロック付き)、時間帯終了後にバックグラウンドスケジューラが配信します:
- 同じ宛先のL1通知は1通にマージされます(
🌙 勿擾時段收到 3 則通知:+ 番号付きリスト)。3通の別々のメッセージにはなりません。 - 決定カードは個別に配信されます。各カードに固有のボタンがあるためです。
- キューには二重の上限があります:最大500件、36時間を超えたものは破棄(1日半前の「タスクが完了しました」はもうニュースではありません)。破棄時は必ず
warnログを記録し、無音で消えることはありません。
既知の制限:クワイエットアワー中にダッシュボードから処理された決定は、朝になっても期限切れのカードとして配信されます。押しても、その決定のストレージレイヤーが「この決定は処理済み」としてブロックする(fail-closed)ため、代償は余分なカード1枚であり、二重実行は起きません。
ダッシュボードは明示する
Section titled “ダッシュボードは明示する”agent.get RPCのproactiveブロックは2つのフィールドを追加で返します:
quiet_hours— 有効な区間文字列("22:00-08:00")、クワイエットアワーがない場合はnullquiet_hours_note— そのままレンダリングできる繁体字中国語の説明文で、「何が保留され、何がそのまま届くか」を明記
何がサイレンスされるのかは、ユーザーが見える場所に開示されなければなりません。これは必須要件であり、加点項目ではありません。
デイリーダイジェスト
Section titled “デイリーダイジェスト”[notify]daily_digest = false # デフォルト無効、明示的に有効化が必要daily_digest_at = "09:00" # ローカル時刻1日1通、[general] default_agentの[proactive]宛先にプッシュされ、過去24時間をまとめます:
- 完了タスク数
- 判断待ち件数(承認 + インストール申請 +
needs_humanで止まっているタスク) - 学習イベント数(
gvu_*/playbook_*/soul_*/evolution_*アクティビティフィードイベント) - 支出
- チャネル異常アラート回数
- 行動率が低すぎる通知タイプ(次節参照)
何もない日は送らない
Section titled “何もない日は送らない”すべてがゼロの日は、メッセージを送りません——「本日異常なし」の1通すら送りません。毎日読み飛ばせるダイジェストは、読むべき日にも読み飛ばすことをあなたに学習させてしまうからです。
上限は厳格に1日1通:状態ファイルが「最後に送信したローカル日付」を記録するため、再起動しても2通目は送られません。ただし09:00にgatewayが停止していて11:00に起動した場合、その日のダイジェストは1回だけ送信されます。
従業員ダイジェスト(「不在の間に」)
Section titled “従業員ダイジェスト(「不在の間に」)”2026-10-08 追加(v1.72.0)。上のデプロイ全体の [notify] daily_digest とは別物です。
[digest]enabled = true # 既定はオン。false でオフhour = 8 # 現地時刻の時(0–23)timezone = "Asia/Taipei" # IANA タイムゾーン、既定は UTCexclude_agents = [] # 対象外にする従業員毎日 timezone の hour 以降に、ゲートウェイのインスタンスロック(duduclaw_core::gateway_instance)を持つゲートウェイが、モデルを呼ばずに従業員ごとの要約を組み立てます(duduclaw-gateway/src/digest.rs):前回のダイジェスト以降(最大 7 日前まで)に完了したタスクとゴール、アクティビティ、承認待ち、精算済みの継続タスク実行、費用。報告することのない従業員は省きます。ダイジェストは <home>/digest/<date>.json に保存され、ダッシュボードのホーム(RPC digest.latest、閲覧者が読める従業員とタスクだけ)に表示され、有効な管理者全員の確認済み連携チャネルへ送られます。<home>/digest/state.json が送信前にファイルロック下で現地日付を確保するため、再起動や 2 台目のゲートウェイで二重送信はしません(確保後・送信前のクラッシュではその日はスキップ)。既定はオンです(2026-10-08 オーナー決定)。[digest] セクションがなければ UTC 08:00 に動きます。enabled = false でオフになり、解析できない config.toml や [digest] 内の不正な値もオフ扱いです。
ホームの完了項目にはそれぞれ 👍/👎/「修正が必要」(任意のメモ付き)があります。RPC digest.feedback には従業員に対する Operator とタスクの公開範囲チェックが必要で、done のタスクだけが対象です。行は <home>/feedback.jsonl(進化の振り返りのユーザーフィードバック信号が既に読むファイル)に、type positive/negative/correction と source = "deliverable"、item_kind、item_id、verdict、user_id、note 付きで追記されます。能動メッセージの「無視された」判定はこれらの行を読み飛ばします。
ファイル。 各従業員の欄には、期間中に納品したファイル(artifacts.jsonl 台帳の外向きの行、新しい順に 5 件)も載り、それぞれ同じフィードバックができます:digest.feedback { artifact: { agent_id, archived_name }, verdict, note? }。タスクに属するファイルはそのタスクと同じ権限が必要です(done の条件は除く)。タスクのないファイルは従業員に対する Operator が必要です。行の item_kind は artifact、item_id は artifact:<従業員>/<保存名> です。存在しないファイルと拒否は同じ応答になります。
チャネルのボタン。 チャネルのメッセージは表示する項目に番号を付け([1]、[2]…。従業員ごとに完了項目とファイルを最大 3 件ずつ)、Telegram、Discord、Slack、LINE では最初の 4 項目に 👍/👎/✏️ ボタンを付けます(Discord は 1 メッセージに 5 行まで、LINE はクイックリプライ 13 件まで)。押したときの認可は digest.feedback と同じです:押した人のチャネル ID が確認済みで有効なダッシュボードのアカウント(押した時点で再読込)に対応し、そのアカウントが従業員に対する Operator を持ち、タスクの公開範囲がそのアカウントとそのチャネルの両方を許し、タスクが done であること。Telegram、Discord、Slack ではさらにチャネル自体のアクセス設定を先に通ります。ボタンにはメモを付けられないため、チャネルからの「修正が必要」はメモなしで記録され、返信でダッシュボードでの補足を案内します。チャネルの本文では、公開範囲にそのチャネルが含まれないタスクはタイトルを出さず番号だけを表示します(channel_may_read_task)。
おやすみ時間。 チャネルへの配信は L1 の通知で、デプロイ全体の [notify] quiet_hours に従います(ダイジェストは特定の従業員に属さないため)。時間帯内は notify_queue.jsonl に種類 digest として入り、時間帯が終わると保存済みのダイジェストから組み立て直して送られ、他の通知とまとめられません。送信のたびに行動率の統計で digest.daily として数えます。実チャネルでは未検証です。
返信が遅いときの途中経過
Section titled “返信が遅いときの途中経過”2026-10-08 追加(v1.72.0)、既定はオン(2026-10-08 オーナー決定)。
[channel_reply]interim_status = true # 遅いターンに 1 行の状況を送る。false でオフinterim_status_secs = 8 # 0 = オフ(最大 600)interim_status_show_task = false # 従業員が実行中のタスク名を出すか外部チャネル(Telegram、Discord、Slack、LINE、WhatsApp、Feishu、Google Chat、Teams、WeCom、DingTalk。WebChat と内部セッションは対象外)の返信が interim_status_secs 秒たっても何も表示していないとき、その返信の進捗コールバックにモデルを使わない 1 行が送られます:待った時間と、従業員が取得済みのタスクボードの作業を別に抱えていればその経過時間。タスク名は interim_status_show_task = true で、かつタスクの公開範囲がそのチャネルに読み取りを許す場合だけ含まれます(チャネルの利用者は外部の顧客かもしれないため)。1 ターンに最大 1 回、進捗コールバックのないチャネルでは送りません。その場で編集できるチャネルでは、後で回答に置き換わる進捗メッセージの最初の状態として表示され、それ以外では別メッセージになります(LINE と WhatsApp ではプッシュ枠を使います)。CLI の返信の多くは 8 秒を超えるため、そうしたチャネルではほぼ毎ターン 1 通増えます。枠が気になる場合は interim_status = false にするか interim_status_secs を上げてください。解析できない config.toml や型の誤りはオフ扱いです(duduclaw-gateway/src/channel_reply/interim.rs)。
そのターンで従業員のモデルが思考中であることを報告した場合、同じ 1 行は「💭 已收到,正在思考(已 N 秒)。」に変わります。思考の内容がチャネルに送られることはなく、チャネルに伝わるのは思考していたという事実だけです。各ランタイムが思考を報告するのは後続のリリースからで、それまでこの行は「⏳ 已收到,正在處理…」のままです。すべてのチャネルの進捗は 1 つの許可リスト(ツールの動き、タスクボード、処理中、この状況行。WhatsApp は従来どおりタスクボードだけ)を通ります。ツールステップの境界、実際のモデル名、思考といったダッシュボード専用のイベントはどのチャネルにも届きません。
行動率の計測
Section titled “行動率の計測”通知を送信するたびに1件、誰かが実際に決定を処理するたびに1件記録し、~/.duduclaw/notify_events.jsonlに保存します。
duduclaw dashboard → notify.stats RPC{ "days": 30, "broken_threshold": 0.5, "min_sample": 10, "types": [ { "type": "decision.install", "pushed": 12, "actionable": 12, "acted": 5, "action_rate": 0.42, "broken": true }, { "type": "decision.goal", "pushed": 12, "actionable": 12, "acted": 12, "action_rate": 1.0, "broken": false } ]}brokenはGoogle SREの判定基準をそのまま採用しています:精度が50%を下回るアラートは壊れたアラート。条件は「押せる」プッシュが10件以上、かつ行動率が50%未満であることです。
意図的な設計がいくつかあります:
- 同じカードを2回押しても行動は1回(決定idで照合し、単純カウントはしない)。したがって行動率が100%を超えることはありません。
- 拒否された押下は行動に数えません——押したが権限や状態にブロックされたのは失敗したインタラクションであり、「この通知は有用だった」証拠にはなりません。
- 純粋な周知タイプ(押すボタンがない)は
actionable: 0を報告し、決してbrokenとマークされません。「FYIの行動率は0%」はトートロジーであり、発見ではありません。
ダッシュボードのチャートは実装済みです。場所は「ダッシュボードの入口」の節を参照してください。
チャネル障害レコードのスキーマ
Section titled “チャネル障害レコードのスキーマ”channel_failures.jsonlには2つの調整が入りました:
-
channelフィールドの普及:session idからプラットフォームを導出できるすべての書き込みポイント(channel_reply_silent/channel_reply_fallback/runtime_fallback_substitution/trajectory_anomaly/foresight_alarm)がchannelを記録するようになり、ダッシュボードの統合ログが「この失敗はどのプラットフォームで起きたか」に答えられるようになりました。プラットフォームを導出できないもの(cron / bus / heartbeatなどの内部session、および作業ディレクトリしか分からないPTY fallback)はnullを書くか省略します——帰属を捏造しません。 -
回復イベント:チャネルがアラート状態から正常に戻ったとき、1件書き込みます
{"event":"channel_recovered","channel":"telegram","reason":"recovered","resolved":true,"resolves":"telegram_send_failed","timestamp":"…"}古い失敗行は書き換えられません(append-onlyの監査ファイル)。コンシューマー側は「同じchannelにより新しい
channel_recoveredがあるか」でその障害がまだ有効かを判断します。
⚠️ 付随する挙動修正:チャネル障害アラートの判定条件が「channelフィールドがある」から「eventが送信失敗の許可リストに含まれ、かつchannelフィールドがある」に変わりました。このステップがなければ、1点目の変更によってLLMタイムアウトや軌跡異常のすべてがチャネル断としてアラートされてしまいます。許可リストは現在telegram_send_failedのみ。新しい項目を追加するには、そのイベントが本当に「このチャネルからメッセージを送れない」ことを意味していなければなりません。
ダッシュボードの入口
Section titled “ダッシュボードの入口”これらの設定・情報にはすべてRPCがありますが、入口は複数のページに分散しています:
| 設定・情報 | 場所 | 説明 |
|---|---|---|
クワイエットアワー(AI社員自身のquiet_hours、フルコンテキスト) |
AI社員一覧 → 社員を選択 → 編集(/agents/:id/edit)→「自動化」タブ |
詳細ブロックにHH:MM-HH:MM入力欄があり、フォーマットエラーはその場で赤くマークされます。その下に、この社員の現在有効な説明文(quiet_hours_note)がリアルタイム表示されます。 |
| クワイエットアワー(社員をまたいだ素早い切り替え) | システム設定 →「主動行為」(プロアクティブ動作)タブ | 同じ[proactive] quiet_hoursで、ページ上部の社員ドロップダウンで切り替え可能。新フォーマットのフィールドが空で、レガシーの数値式quiet_hours_start/quiet_hours_endがデフォルト以外に変更されていた場合(オペレーターが過去にこのページでクワイエットアワーを設定していた証拠)、変換プロンプトが表示され新フォーマットに自動プレフィルされます。保存して初めて有効になり、「クワイエットアワーを変えたつもりが、ガバナンスレイヤーからは全く見えていなかった」というサイレントな罠を回避します(W2-9)。 |
デイリーダイジェストのスイッチ([notify] daily_digest / daily_digest_at) |
システム設定 →「系統」(システム)タブ | スイッチ + 時刻フィールド。グローバルで1通、社員ごとの設定ではありません。 |
通知効果(notify.stats行動率) |
分析レポートページ →「通知成效」(通知効果)カード | 「行動率の計測」の節のSRE 50%基準に基づき、通知タイプごとのプッシュ数/行動率を一覧表示。行動率が低すぎるタイプはバーが赤くなり、ヒント文が1行付きます(色だけに頼らず、色覚多様性や白黒印刷でも判別可能)。純粋な周知タイプ(押すボタンがない)は決して壊れているとマークされません。 |
同じ[proactive] quiet_hoursに編集入口が2つあります:社員編集フォームの「自動化」タブはフルコンテキスト(通知宛先やcheck-inスケジュールと並んでいる)で、システム設定の「主動行為」タブは社員詳細を開かずに複数の社員のクワイエットアワーを素早く確認・切り替えするためのショートカットです。どちらも同じagent.tomlフィールドに書き込み、保存すれば即座に相互反映され、食い違いは起きません。
- 設計根拠:
commercial/design/reviews/ux-redesign-2026-08/02-ux-methodology.mdテーマ4(P4-1、P4-4、P4-5、P4-6)および03-analogous-products.mdC1/C7/C8/C12 - コード:
crates/duduclaw-gateway/src/notify_governance.rs、notify_stats.rs、notify_digest.rs - フロントエンド:
web/src/pages/agent-form/EditAgentPage.tsx(自動化タブ)、web/src/components/settings/sections/ProactiveTab.tsx、SystemTab.tsx、web/src/pages/ReportPage.tsx - 関連機能:34-goal-loop.md(needs_human決定カード)、23-autopilot-engine.md(ルールのサーキットブレーカー通知)