From bbffd37884cd039cc85df96a2eb155142d1895bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Sat, 3 Oct 2026 09:57:43 +0900 Subject: [PATCH 1/2] feat(discord): notify on task done/blocked/failed, not just pr-ready Captain asked for a Discord ping on every completed task plus failures and auth/approval blockers, not just the existing merge-ready notification. --- bin/fm-discord-notify-status.sh | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bin/fm-discord-notify-status.sh b/bin/fm-discord-notify-status.sh index fa03b7fd52f..efcacb4cd75 100755 --- a/bin/fm-discord-notify-status.sh +++ b/bin/fm-discord-notify-status.sh @@ -8,6 +8,22 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-discord-lib.sh +. "$SCRIPT_DIR/fm-discord-lib.sh" + +# Push a plain one-line message to this home's configured Discord channel, +# reusing fm-discord-notify.sh --report's send path. Silent no-op when Discord +# is not configured (fm-discord-notify.sh --report already fails loudly only +# on a missing token, so the token check here keeps this path a quiet no-op +# instead of an error on a Discord-less home). +fm_discord_send_plain_report() { + local message=$1 channel_id + fm_discord_load_config + [ -n "${FM_DISCORD_TOKEN:-}" ] || return 0 + channel_id=$(fm_discord_trim "${FM_DISCORD_CHANNELS%%,*}") + case "$channel_id" in ''|*[!0-9]*) return 0 ;; esac + "$SCRIPT_DIR/fm-discord-notify.sh" --report "$channel_id" "$message" +} [ "$#" -eq 2 ] || exit 2 task_id=$1 @@ -61,5 +77,20 @@ case "$verb:$key" in "$SCRIPT_DIR/fm-discord-notify.sh" pr-ready "$route_task_id" "$key" \ "$summary" "병합|열어 두기" "$task_id" ;; + done:*) + note=$(status_line_note "$line") + note=$(printf '%s' "$note" | tr '\n\r' ' ') + fm_discord_send_plain_report "작업 완료 [$task_id]: ${note:-완료}" + ;; + blocked:*) + note=$(status_line_note "$line") + note=$(printf '%s' "$note" | tr '\n\r' ' ') + fm_discord_send_plain_report "확인 필요(막힘) [$task_id]: ${note:-원인 미기재}" + ;; + failed:*) + note=$(status_line_note "$line") + note=$(printf '%s' "$note" | tr '\n\r' ' ') + fm_discord_send_plain_report "작업 실패 [$task_id]: ${note:-원인 미기재}" + ;; *) exit 0 ;; esac From 627dc24a734079dff81a94749a2686121eee5367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Sat, 3 Oct 2026 10:59:49 +0900 Subject: [PATCH 2/2] no-mistakes(review): Update stale doc/comment, add tests for done/blocked/failed notify --- bin/fm-watch.sh | 7 +-- docs/scripts.md | 2 +- tests/fm-discord-decision-push.test.sh | 66 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 8eb9137ee3c..0b856877db9 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1929,9 +1929,10 @@ signal_files_actionable() { # ... return "$found" } -# Push only the two additional captain-facing transitions that originate in -# worker status logs. Captain holds and local PR registration publish directly -# at their durable mutation sites, where their identities are authoritative. +# Push the captain-facing transitions that originate in worker status logs: +# yolo-off PR-ready, plus done/blocked/failed plain reports. Captain holds and +# local PR registration publish directly at their durable mutation sites, +# where their identities are authoritative. signal_discord_decision_notifications() { # ... local f start size chunk line task verb key for f in "$@"; do diff --git a/docs/scripts.md b/docs/scripts.md index d50452a871e..52f12bd623c 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -152,7 +152,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-discord-lib.sh` | Shared self-hosted Discord configuration and channel selection | | `fm-discord-notify.sh` | Push a keyed captain decision or requested report to the configured self-hosted Discord channel and retry pending sends | | `fm-discord-report.sh` | Send the bounded fleet snapshot to Discord when quiet mode is active and the captain asks for a report | -| `fm-discord-notify-status.sh` | Map an ask-user gate or yolo-off PR-ready status line to a Discord decision notification | +| `fm-discord-notify-status.sh` | Map an ask-user gate, yolo-off PR-ready, or done/blocked/failed status line to a Discord decision notification or plain report | | `fm-discord-notify.js` | Persist and deliver self-hosted Discord decision notifications through the REST API | | `fm-discord-poll.sh` | Invoke the bounded self-hosted Discord poll for mentions and authorized decision replies | | `fm-discord-poll.js` | Poll Discord messages, capture authorized decision replies, and emit inbox wakes | diff --git a/tests/fm-discord-decision-push.test.sh b/tests/fm-discord-decision-push.test.sh index 6cb60251df8..2db6c899983 100644 --- a/tests/fm-discord-decision-push.test.sh +++ b/tests/fm-discord-decision-push.test.sh @@ -530,6 +530,69 @@ test_pr_push_names_gitlab_project() { pass "PR-ready notifications name the project for an accepted GitLab merge-request URL too" } +test_done_status_sends_plain_report() { + local home log body + home="$TMP_ROOT/done-status" + mkdir -p "$home/state/x-context" + chmod 700 "$home/state" "$home/state/x-context" + make_fake_node "$home" + log="$home/posts.jsonl" + FM_TEST_REAL_NODE=$(command -v node) FM_DISCORD_FAKE_POST_LOG="$log" \ + PATH="$home/fake-bin:$BASE_PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DISCORD_BOT_TOKEN=fake-token FM_DISCORD_CHANNEL_ID=1000000000000000001 \ + "$ROOT/bin/fm-discord-notify-status.sh" task-a \ + 'done: wired up the new endpoint' >/dev/null \ + || fail "done status classification failed" + assert_equals "1" "$(wc -l < "$log" | tr -d '[:space:]')" "done status sends exactly one report" + assert_contains "$(jq -r '.url' "$log")" "1000000000000000001" "done status sends to configured channel" + body=$(jq -r '.payload.content' "$log") + assert_contains "$body" "task-a" "done report includes task id" + assert_contains "$body" "wired up the new endpoint" "done report includes the note text" + pass "a done status line sends a plain Discord report with the worker's note" +} + +test_blocked_status_sends_plain_report() { + local home log body + home="$TMP_ROOT/blocked-status" + mkdir -p "$home/state/x-context" + chmod 700 "$home/state" "$home/state/x-context" + make_fake_node "$home" + log="$home/posts.jsonl" + FM_TEST_REAL_NODE=$(command -v node) FM_DISCORD_FAKE_POST_LOG="$log" \ + PATH="$home/fake-bin:$BASE_PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DISCORD_BOT_TOKEN=fake-token FM_DISCORD_CHANNEL_ID=1000000000000000001 \ + "$ROOT/bin/fm-discord-notify-status.sh" task-b \ + 'blocked: waiting on API credentials' >/dev/null \ + || fail "blocked status classification failed" + assert_equals "1" "$(wc -l < "$log" | tr -d '[:space:]')" "blocked status sends exactly one report" + assert_contains "$(jq -r '.url' "$log")" "1000000000000000001" "blocked status sends to configured channel" + body=$(jq -r '.payload.content' "$log") + assert_contains "$body" "task-b" "blocked report includes task id" + assert_contains "$body" "waiting on API credentials" "blocked report includes the note text" + pass "a blocked status line sends a plain Discord report with the worker's note" +} + +test_failed_status_sends_plain_report() { + local home log body + home="$TMP_ROOT/failed-status" + mkdir -p "$home/state/x-context" + chmod 700 "$home/state" "$home/state/x-context" + make_fake_node "$home" + log="$home/posts.jsonl" + FM_TEST_REAL_NODE=$(command -v node) FM_DISCORD_FAKE_POST_LOG="$log" \ + PATH="$home/fake-bin:$BASE_PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DISCORD_BOT_TOKEN=fake-token FM_DISCORD_CHANNEL_ID=1000000000000000001 \ + "$ROOT/bin/fm-discord-notify-status.sh" task-c \ + 'failed: build script exited 1' >/dev/null \ + || fail "failed status classification failed" + assert_equals "1" "$(wc -l < "$log" | tr -d '[:space:]')" "failed status sends exactly one report" + assert_contains "$(jq -r '.url' "$log")" "1000000000000000001" "failed status sends to configured channel" + body=$(jq -r '.payload.content' "$log") + assert_contains "$body" "task-c" "failed report includes task id" + assert_contains "$body" "build script exited 1" "failed report includes the note text" + pass "a failed status line sends a plain Discord report with the worker's note" +} + test_no_token_is_inert test_quiet_report_posts_plain_snapshot test_report_requires_token @@ -550,3 +613,6 @@ test_ask_user_gate_alone_triggers_no_push test_ask_user_escalation_hold_carries_finding_text test_pr_push_requires_yolo_off test_pr_push_names_gitlab_project +test_done_status_sends_plain_report +test_blocked_status_sends_plain_report +test_failed_status_sends_plain_report