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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
88
src/handoff/escalation-notifier.ts
Normal file
88
src/handoff/escalation-notifier.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user