feat: review DoD 체크리스트 + 자매 페르소나 톤 + 에스컬레이션 디스코드 알림

자기야 묶음 요청 (1+2+3):
  1) 에스컬레이션 발생 시 디스코드 SOS 알림
  2) review prompt 를 DoD 체크리스트로 강제
  3) 디스코드 stage 알림 톤 자매별 페르소나로 다양화

## #2: review DoD 체크리스트화

prompts.ts 의 reviewHintForRole 강화:
- manager: 자유 prose 금지, 정해진 출력 형식 강제
    ## DoD 체크리스트
    - [✓|✗] <항목> — <근거>
    ## 최종 결정
    APPROVE | REQUEST_CHANGES | ABORT
    ## 결정 근거
    <한 문단>
  - "✓" 가 아닌 "통과/OK" 같은 단어 금지 (parser 가 못 잡음)
  - minor/스타일 결함은 REQUEST_CHANGES 사유로 카운트 안 함
  - 사용자가 의도한 모순 (e.g. "함수 비워줘") 은 새 DoD 로 인식
- principal: [critical|major] <어디> — <무엇> — <왜> — <고침> 형식
- lead: ✓/✗ <기능명>: 동작 OK/실패 — <근거> 형식
- junior: 동일 verdict 시작 + 한 문단

## #3: 디스코드 페르소나 톤

discord-notify.ts:
- START_POOLS / END_POOLS 를 자매별 4 variant 풀로 확장
  - 하랑 차분/단정, 나랑 활달, 다랑 꼼꼼, 이랑 차분/믿음직
- 안정적 픽: pipeline title 해시로 결정 → 같은 task 같은 line, 다른 task
  로테이션 → robotic 느낌 제거
- 새 함수 renderEscalation(): SOS 메시지 포맷 (멘션 + 사유 + 대시보드 링크)

## #1: 에스컬레이션 디스코드 알림

흐름:
  rails orchestrator 가 escalated 시점 도달
  → recordEscalation() 의 EscalationNotifier 인터페이스 호출
  → DiscordEscalationNotifier 구현체가 sister-agent (하랑이) 의 /notify
    엔드포인트에 POST
  → sister-agent 가 local openclaw CLI 로 디스코드 채널에 메시지 발송
  → 자기야 멘션 + 사유 + pipelineId + 복구 명령

신설:
- src/handoff/escalation-notifier.ts: DiscordEscalationNotifier 클래스
- sister-agent/src/server.ts: POST /notify 엔드포인트 (channelId + message)
- src/server/http.ts ServerOpts 에 escalationConfig 옵션 추가. 시작
  엔드포인트가 notifyChannelId 를 받으면 per-pipeline notifier 인스턴스
  생성 → runPipeline 의 notifier opt 로 주입
- src/cli/serve.ts: RAILS_NOTIFY_SISTER_URL + RAILS_NOTIFY_USER_ID 환경
  변수 읽어 startHttpServer 에 escalationConfig 전달

운영 시 활성화: Dev VM 의 .env 에 다음 두 줄 추가
  RAILS_NOTIFY_SISTER_URL=http://10.10.10.112:18801
  RAILS_NOTIFY_USER_ID=452664876691881984

113 테스트 그대로 통과.
This commit is contained in:
2026-04-11 05:11:53 +09:00
parent 185320f1b9
commit fc646894d9
6 changed files with 349 additions and 42 deletions

View File

@@ -77,9 +77,17 @@ export async function notifyDiscord(opts: {
}
/**
* Per-sister persona templates for stage start / end messages.
* Keep them short and sister-flavored. Each entry maps stage → {start, end}
* where end is a function that takes a one-line summary.
* Per-sister persona message pools.
*
* Each sister has a personality (from .openclaw/workspace/SOUL.md):
* harang — planner / 차분하고 어른스러운 언니톤. 짧고 단정.
* narang — developer / 활달하고 손이 빠른 동생. 능률적, 약간 캐주얼.
* darang — qa / 꼼꼼하고 약간 까칠한, 정확함을 좋아하는.
* erang — infra / 차분하고 믿음직, 기술적이지만 부드러움.
*
* Pools have multiple variants so the channel doesn't feel robotic. We
* pick by hashing the pipeline title — same task always gets the same
* line, but different tasks rotate.
*/
export interface StageMessageContext {
agentName: string;
@@ -89,20 +97,79 @@ export interface StageMessageContext {
filesProduced?: number;
}
const START_POOLS: Record<string, string[]> = {
harang: [
`📋 자, 기획 들어갈게. *{title}* 일단 범위부터 잡아둘게.`,
`📋 *{title}* — 어떤 게 MVP 안에 들어가야 할지 정리할게.`,
`📋 *{title}*, 통과 기준 먼저 정해놓고 갈게.`,
`📋 기획 시작 — *{title}*. 비범위도 명확히 박아둘게.`,
],
narang: [
`🔨 *{title}* 받았어! 바로 짜볼게.`,
`🔨 코드 작성 시작 — *{title}*. 후딱 만들어볼게.`,
`🔨 *{title}* 구현 들어간다. 파일 세팅부터.`,
`🔨 받았어 *{title}*. 손이 근질근질해.`,
],
darang: [
`🔍 *{title}* — 어디 어디 봐야 하나 체크리스트 뽑을게.`,
`🔍 리뷰 시작. *{title}* 한 줄씩 꼼꼼히 볼게.`,
`🔍 *{title}*, 통과 기준 항목별로 검사 들어갈게.`,
`🔍 *{title}* — 빠진 거 있나 보자.`,
],
erang: [
`🚀 *{title}* 배포 검증 시작. 환경부터 확인할게.`,
`🚀 *{title}*, 무리 없이 띄울 수 있는지 보고 올게.`,
`🚀 배포 단계 진입 — *{title}*. 안전하게 올려볼게.`,
`🚀 *{title}* 인프라 점검 들어갈게.`,
],
};
const END_POOLS: Record<string, string[]> = {
harang: [
`📋 기획 끝. 통과 기준 박아놨으니 나랑이 받아.`,
`📋 범위 잡혔어. 나랑아 부탁해.`,
`📋 정리 끝났어. 다음은 구현이야.`,
`📋 plan 완료. 나랑이가 받아갈 차례.`,
],
narang: [
`🔨 구현 끝났어{tail}. 다랑이 리뷰 부탁해.`,
`🔨 일단 다 박았어{tail}. 다랑아 봐줘.`,
`🔨 코드 정리 끝{tail}. 검수 넘긴다.`,
`🔨 implement 마무리{tail}. 다음은 review.`,
],
darang: [
`🔍 리뷰 통과! 이랑이 받아.`,
`🔍 체크리스트 다 ✓. 배포로 넘길게.`,
`🔍 큰 문제 없어. 이랑아 배포 검증 부탁해.`,
`🔍 review 통과 — 다음은 이랑이.`,
],
erang: [
`🚀 배포 검증 완료{tail}. 안전해.`,
`🚀 환경 점검 OK{tail}. 띄울 수 있어.`,
`🚀 deploy 끝{tail}. 자기야 확인해줘.`,
`🚀 검증 완료{tail}. 무리 없이 동작해.`,
],
};
/** Stable picker — same input gets same line. */
function pickFromPool(pool: string[], seed: string): string {
if (pool.length === 0) return "";
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
}
const idx = Math.abs(hash) % pool.length;
return pool[idx]!;
}
export function renderStageStart(ctx: StageMessageContext): string {
const title = ctx.taskTitle.slice(0, 80);
switch (ctx.agentName) {
case "harang":
return `📋 내가 기획 시작할게 — *${title}*`;
case "narang":
return `🔨 구현 시작할게 — *${title}*`;
case "darang":
return `🔍 리뷰 시작할게 — *${title}*`;
case "erang":
return `🚀 배포 검증 시작할게 — *${title}*`;
default:
return `▶️ ${ctx.stage} 시작 — *${title}*`;
}
const pool = START_POOLS[ctx.agentName];
if (!pool) return `▶️ ${ctx.stage} 시작 — *${title}*`;
return pickFromPool(pool, ctx.agentName + ":start:" + title).replace(
"{title}",
title,
);
}
export function renderStageEnd(ctx: StageMessageContext): string {
@@ -110,16 +177,43 @@ export function renderStageEnd(ctx: StageMessageContext): string {
ctx.filesProduced && ctx.filesProduced > 0
? ` (산출물 ${ctx.filesProduced}개)`
: "";
switch (ctx.agentName) {
case "harang":
return `📋 기획 끝났어. 나랑이한테 넘길게${tail}`;
case "narang":
return `🔨 구현 끝났어${tail}. 다랑이한테 리뷰 넘길게`;
case "darang":
return `🔍 리뷰 통과! 이랑이한테 배포 넘길게${tail}`;
case "erang":
return `🚀 배포 검증 완료${tail}`;
default:
return `${ctx.stage} 완료${tail}`;
}
const pool = END_POOLS[ctx.agentName];
if (!pool) return `${ctx.stage} 완료${tail}`;
return pickFromPool(
pool,
ctx.agentName + ":end:" + ctx.taskTitle,
).replace("{tail}", tail);
}
/**
* Render an escalation alert. Used by the orchestrator when a pipeline
* exhausts retry / replan budget and needs the user to step in.
*/
export interface EscalationContext {
pipelineId: string;
projectName: string;
reason: string;
stage: string;
attempts: number;
/** Discord user ID to mention. If empty, no mention. */
mentionUserId?: string;
}
export function renderEscalation(ctx: EscalationContext): string {
const mention = ctx.mentionUserId ? `<@${ctx.mentionUserId}> ` : "";
const short = ctx.pipelineId.slice(0, 8);
return [
`${mention}🚨 **자기야, 막혔어** — 사람이 봐야 할 것 같아`,
``,
`**프로젝트:** ${ctx.projectName}`,
`**단계:** ${ctx.stage}`,
`**시도:** ${ctx.attempts}`,
`**사유:** ${ctx.reason.slice(0, 600)}`,
``,
`Pipeline ID: \`${ctx.pipelineId}\``,
`대시보드: https://hanarang.nabomhalang.co.kr/rails`,
``,
`복구하려면:`,
`\`bash ~/.openclaw/skills/hanarang-rails/scripts/rails-status.sh ${short}\``,
].join("\n");
}

View File

@@ -164,6 +164,11 @@ function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
* Review-stage hints: every role outputs an actual verdict, never a team
* plan. The manager is the FINAL authority and must commit to APPROVE or
* REQUEST_CHANGES — no decomposition, no delegation, no "수석 1명은…" lists.
*
* Manager output format is locked into a DoD checklist. The reviewer must
* extract concrete acceptance criteria from priorStages.plan ("통과 기준",
* "MVP 범위") and check each one against the implement result. This forces
* the LLM to think in terms of testable items instead of generic prose.
*/
function reviewHintForRole(role: Role): string {
switch (role) {
@@ -171,30 +176,66 @@ function reviewHintForRole(role: Role): string {
return [
`너는 review 단계의 최종 결정권자다. 절대 작업을 분해하거나 팀(수석/선임/신입)을 배치하지 마. 본인이 직접 결정한다.`,
``,
`위 priorStages 에 들어 있는 implement 결과물 (코드 본문) 을 처음부터 끝까지 읽고, 다음 형식으로만 답해:`,
`## 입력`,
`위 priorStages 에는 다음이 들어 있다:`,
`- plan 단계 결과: 하랑이가 정한 MVP 범위 / 비범위 / 통과 기준`,
`- implement 단계 결과: 나랑이가 만든 실제 코드 본문 (잘리지 않은 원본)`,
``,
`1) 첫 줄: \`APPROVE\` 또는 \`REQUEST_CHANGES\` 또는 \`ABORT\` 중 하나.`,
`2) 둘째 줄부터: 그 판정의 핵심 근거를 1~3개 bullet 로. 각 bullet 은 어떤 파일의 어떤 부분이 어떤 이유로 통과/미달인지 구체적으로.`,
`## 작업 절차 (정확히 이 순서)`,
`1. plan 단계의 "MVP 범위" 와 "통과 기준" 에서 **검증 가능한 항목** 을 3~6개 추출한다. 추상적인 항목 말고 구체적으로 코드에서 확인 가능한 것 (예: "추가 버튼이 있고 동작함", "삭제 후 새로고침 시 유지됨").`,
`2. 각 항목을 implement 코드에서 직접 찾아 통과/미달 판정한다.`,
`3. 모든 항목이 통과면 APPROVE, 하나라도 미달이면 REQUEST_CHANGES, 본질적으로 잘못된 접근이면 ABORT.`,
``,
`금지: 작업 분배, 가상 팀 구성, "내가 마지막에 본다" 같은 미래 약속, 코드를 다시 짜는 행위.`,
`허용: APPROVE 한 줄 + 짧은 이유 / REQUEST_CHANGES + 결함 bullet.`,
`## 출력 형식 (정확히 이대로)`,
``,
`\`\`\``,
`## DoD 체크리스트`,
`- [✓|✗] <항목 1 한 줄 설명> — <근거: 어떤 파일의 어떤 부분에서 확인됨>`,
`- [✓|✗] <항목 2 한 줄 설명> — <근거>`,
`- [✓|✗] <항목 3 한 줄 설명> — <근거>`,
`(필요하면 더)`,
``,
`## 최종 결정`,
`APPROVE | REQUEST_CHANGES | ABORT`,
``,
`## 결정 근거`,
`<한 문단 — 어떤 항목이 결정적으로 통과/미달인지 한국어로>`,
`\`\`\``,
``,
`## 엄격한 금지`,
`- 작업 분배, 가상 팀 구성, "수석/선임/신입" 단어 사용`,
`- "내가 마지막에 본다" 같은 미래 약속`,
`- 코드를 다시 작성하거나 새 코드 제안 (그건 implement 단계의 일)`,
`- DoD 체크리스트 없이 prose 만 출력하는 것 (반드시 위 형식)`,
`- "✓" 가 아닌 "통과", "OK" 같은 단어 사용 (파서가 못 잡음)`,
``,
`## 보너스 규칙`,
`- 사용자가 요구사항에 의도적으로 모순/제한 (예: "함수를 비워줘") 을 넣었으면 그건 새 DoD 다. 그 의도를 충족하면 APPROVE.`,
`- minor 한 스타일 / 주석 누락은 REQUEST_CHANGES 가 아니다. critical/major 만 카운트.`,
].join("\n");
case "principal":
return [
`너는 기술 리뷰 담당이다. 코드 본문을 보고 critical 결함만 1~3개 골라 bullet 로 정리해.`,
`각 bullet 은: [심각도] 어디(파일/라인/함수) — 무엇이 — 왜 문제 — 어떻게 고쳐야`,
`심각도는 critical / major / minor 중 하나.`,
`작업을 분배하거나 팀을 구성하지 마.`,
`너는 기술 리뷰 담당이다. plan 의 통과 기준과 implement 코드를 보고 critical/major 결함만 1~3개 골라 bullet 로 정리해.`,
``,
`형식 (정확히 이대로):`,
`- [critical|major] <어느 파일/라인/함수> — <무엇이 문제> — <왜 문제> — <어떻게 고쳐야>`,
``,
`minor / recommendation 은 적지 마. 작업을 분배하거나 팀을 구성하지 마.`,
].join("\n");
case "lead":
return [
`너는 기능 동작 검증 담당이다. 사용자 요구사항의 각 핵심 기능 (예: 추가, 수정, 삭제, 완료체크, 새로고침 후 유지) 별로 한 줄씩 통과 여부와 근거를 적어.`,
`형식: "✓ <기능명>: 동작 OK — <근거>" 또는 "✗ <기능명>: 실패 — <원인>"`,
`작업을 분배하거나 신입에게 위임하지 마.`,
`너는 기능 동작 검증 담당이다. plan 의 "통과 기준" 에서 핵심 기능을 추출하고, 각 기능별로 implement 코드에서 동작 여부를 한 줄씩 적어.`,
``,
`형식 (정확히 이대로):`,
`✓ <기능명>: 동작 OK — <근거: 어느 함수가 어떻게 처리>`,
`✗ <기능명>: 실패 — <원인: 어떤 코드가 빠지거나 잘못됨>`,
``,
`작업을 분배하거나 신입에게 위임하지 마. 새 코드 제안 금지.`,
].join("\n");
case "junior":
return [
`위 priorStages 의 implement 결과물 코드를 직접 읽고 평가해. 첫 줄에 \`APPROVE\` 또는 \`REQUEST_CHANGES\` 또는 \`ABORT\` 로만 시작.`,
`위 priorStages 의 implement 결과물 코드를 직접 읽고 plan 의 통과 기준과 비교해 평가해.`,
`첫 줄에 \`APPROVE\` 또는 \`REQUEST_CHANGES\` 또는 \`ABORT\` 로만 시작.`,
`그 다음 줄부터 한 문단 이내로 핵심 이유. 코드를 다시 작성하지 마.`,
].join("\n");
}

View File

@@ -1,7 +1,14 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { z } from "zod";
import { InvokeRequest } from "./types.js";
import { executeInvocation } from "./spawn.js";
import { RailsClient } from "./rails-client.js";
import { notifyDiscord } from "./discord-notify.js";
const NotifyRequest = z.object({
channelId: z.string().min(1),
message: z.string().min(1),
});
const PORT = parseInt(process.env["SISTER_AGENT_PORT"] ?? "18801", 10);
const AGENT_NAME = process.env["SISTER_AGENT_NAME"] ?? "unknown";
@@ -58,6 +65,30 @@ const server = createServer(async (req, res) => {
});
}
// ── /notify — fire a Discord message via the local OpenClaw CLI ──
// Used by the rails orchestrator (or any other internal caller) to
// post messages from this sister's bot identity. Best-effort.
if (method === "POST" && path === "/notify") {
const body = await readJson(req);
const parsed = NotifyRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_notify",
issues: parsed.error.issues,
});
}
const result = await notifyDiscord({
channelId: parsed.data.channelId,
message: parsed.data.message,
});
log(result.ok ? "info" : "warn", "notify", {
channel: parsed.data.channelId,
ok: result.ok,
error: result.error,
});
return sendJson(res, result.ok ? 200 : 502, result);
}
if (method === "POST" && path === "/invoke") {
const body = await readJson(req);
const parsed = InvokeRequest.safeParse(body);

View File

@@ -35,10 +35,23 @@ export default defineCommand({
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
// Optional Discord escalation alert config — set on Dev VM via env so
// pipelines that carry a notifyChannelId auto-generate a notifier
// pointed at one of the sister-agent /notify endpoints.
const escalationSisterUrl = process.env["RAILS_NOTIFY_SISTER_URL"] ?? "";
const escalationUserId = process.env["RAILS_NOTIFY_USER_ID"] ?? "";
const escalationConfig = escalationSisterUrl
? {
sisterUrl: escalationSisterUrl,
...(escalationUserId && { userId: escalationUserId }),
}
: undefined;
const { url, close } = await startHttpServer({
port,
host: args.host ?? "0.0.0.0",
...(args.config && { configPath: args.config }),
...(escalationConfig && { escalationConfig }),
});
log.info(

View File

@@ -0,0 +1,88 @@
import type { EscalationNotifier } from "../resilience/escalate.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "escalation-notifier" });
export interface DiscordEscalationOptions {
/**
* Sister-agent endpoint that owns the Discord bot identity used for the
* alert. Typically harang's sister-agent (port 18801).
*/
sisterUrl: string;
/** Discord channel ID where the alert should land. */
channelId: string;
/** Optional Discord user ID to @-mention in the alert. */
userId?: string;
/** Project name for the message header. */
projectName?: string;
/** Pipeline id (used in formatted message). */
pipelineId: string;
}
/**
* EscalationNotifier that posts a Discord alert via a sister-agent's
* /notify endpoint. The sister-agent then uses its local OpenClaw CLI to
* send the message under that sister's bot identity (so the channel sees
* "하랑이 [bot]" mentioning 자기야 instead of a generic webhook).
*/
export class DiscordEscalationNotifier implements EscalationNotifier {
constructor(private readonly opts: DiscordEscalationOptions) {}
async notify(message: {
title: string;
body: string;
mentionUser?: boolean;
}): Promise<void> {
const mention =
message.mentionUser && this.opts.userId
? `<@${this.opts.userId}> `
: "";
const formatted = [
`${mention}🚨 **자기야, 막혔어** — 사람이 봐야 할 것 같아`,
``,
this.opts.projectName ? `**프로젝트:** ${this.opts.projectName}` : "",
`${message.title}`,
``,
message.body.slice(0, 1500),
``,
`Pipeline ID: \`${this.opts.pipelineId}\``,
`대시보드: https://hanarang.nabomhalang.co.kr/rails`,
]
.filter(Boolean)
.join("\n");
const url = `${this.opts.sisterUrl}/notify`;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
channelId: this.opts.channelId,
message: formatted,
}),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
const txt = await res.text();
log.warn(
{ status: res.status, body: txt.slice(0, 200) },
"escalation notify HTTP error",
);
return;
}
log.info(
{ pipelineId: this.opts.pipelineId, channel: this.opts.channelId },
"escalation notify sent",
);
} catch (err) {
log.warn(
{ err: err instanceof Error ? err.message : String(err) },
"escalation notify threw — non-fatal",
);
}
}
}

View File

@@ -16,6 +16,7 @@ import { loadConfig } from "../config/loader.js";
import type { SisterTransport } from "../handoff/transport.js";
import { buildTransports } from "../handoff/build.js";
import type { EscalationNotifier } from "../resilience/escalate.js";
import { DiscordEscalationNotifier } from "../handoff/escalation-notifier.js";
import {
CreateSubTaskInput,
SubTaskEventInput,
@@ -55,6 +56,15 @@ interface ServerOpts {
onPipelineEvent?: PipelineEventListener;
/** Optional escalation notifier injected into every runPipeline call. */
notifier?: EscalationNotifier;
/**
* Optional Discord escalation alert config. When set, every pipeline that
* carries a notifyChannelId gets an auto-generated DiscordEscalationNotifier
* pointed at that channel.
*/
escalationConfig?: {
sisterUrl: string;
userId?: string;
};
}
export async function startHttpServer(opts: ServerOpts): Promise<{
@@ -99,6 +109,22 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
}
const { project, requirements, notifyChannelId } = parsed.data;
// Build a per-pipeline escalation notifier if both escalationConfig
// and a notifyChannelId are present. This wraps opts.notifier so the
// existing manual override still works for callers that pass one.
let activeNotifier: EscalationNotifier | undefined = opts.notifier;
if (opts.escalationConfig && notifyChannelId) {
activeNotifier = new DiscordEscalationNotifier({
sisterUrl: opts.escalationConfig.sisterUrl,
...(opts.escalationConfig.userId && {
userId: opts.escalationConfig.userId,
}),
channelId: notifyChannelId,
projectName: project,
pipelineId: "pending",
});
}
const result = await runPipeline({
projectName: project,
requirements,
@@ -106,7 +132,7 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
transports,
...(notifyChannelId && { notifyChannelId }),
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
...(opts.notifier && { notifier: opts.notifier }),
...(activeNotifier && { notifier: activeNotifier }),
});
return sendJson(res, 201, {
@@ -135,6 +161,20 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
// immediately, then run the rest in the background under that id.
const { pipelineId } = await createPipeline(project, requirements);
// Build per-pipeline escalation notifier with the actual pipelineId
let activeNotifier: EscalationNotifier | undefined = opts.notifier;
if (opts.escalationConfig && notifyChannelId) {
activeNotifier = new DiscordEscalationNotifier({
sisterUrl: opts.escalationConfig.sisterUrl,
...(opts.escalationConfig.userId && {
userId: opts.escalationConfig.userId,
}),
channelId: notifyChannelId,
projectName: project,
pipelineId,
});
}
void (async () => {
try {
await runPipeline({
@@ -145,7 +185,7 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
transports,
...(notifyChannelId && { notifyChannelId }),
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
...(opts.notifier && { notifier: opts.notifier }),
...(activeNotifier && { notifier: activeNotifier }),
});
} catch (err) {
log.error(