fix(notify): stage-end Discord 메시지가 실제 verdict 와 일치하게

자기야 발견: 다랑이가 디스코드에 "리뷰 통과! 이랑이 받아" 라고 말했는데
실제로는 REQUEST_CHANGES 가 발사돼서 나랑이로 다시 돌아감. 페르소나 메시지
와 실제 흐름이 어긋나는 버그.

원인:
spawn.ts 의 흐름이
  1. manager LLM 호출
  2. children LLM 호출
  3. **Discord stage-end notify 발사**  ← 이 시점엔 verdict 모름
  4. buildSuccessResult() → 여기서 verdict 결정

renderStageEnd 가 verdict 를 안 받아서 항상 "통과" 풀에서 픽함. forced
가드 (RAILS_FORCE_REVIEW_VERDICT) 검증 시에도, 정상 LLM 이 REQUEST_CHANGES
를 내놓을 때도 동일하게 잘못된 메시지가 나감.

수정:
- discord-notify.ts:
  - StageMessageContext 에 verdict 필드 추가
  - END_POOLS 를 verdict 별로 분리:
    * harang/ok, harang/abort
    * narang/ok, narang/error
    * darang/approve, darang/request_changes, darang/abort
    * erang/ok, erang/failed
  - endPoolKey() 함수가 (agentName, verdict) → 풀 키 결정
  - renderStageEnd 가 verdict 를 받아 적절한 풀에서 픽

- spawn.ts:
  - notifyDiscord stage-end 호출을 buildSuccessResult() **이후** 로 이동
  - result.verdict 를 renderStageEnd 에 전달
  - notify 결과도 명시 로깅 (notify.end + verdict)

이제 다랑이는 verdict 에 따라 "리뷰 통과! 이랑이 받아" / "결함 발견 — 나랑아
다시 봐줄래?" / "이건 접근 자체가 잘못된 것 같아. 중단." 중 하나로 답함.
이랑이는 "배포 검증 완료" / "배포 실패 — 자기야 봐줘" 둘 중 하나.
This commit is contained in:
2026-04-11 16:12:55 +09:00
parent fb90abc8e3
commit ece52f9dd5
2 changed files with 113 additions and 23 deletions

View File

@@ -93,6 +93,13 @@ export interface StageMessageContext {
agentName: string;
stage: "plan" | "implement" | "review" | "deploy";
taskTitle: string;
/**
* Verdict produced by the stage. Only used for stage-end messages —
* lets darang say "결함 발견" instead of "통과" when REQUEST_CHANGES,
* lets erang say "배포 실패" instead of "검증 완료" when DEPLOY_FAILED,
* etc. Stage-start ignores this field (verdict isn't known yet).
*/
verdict?: string;
childCount?: number;
filesProduced?: number;
}
@@ -124,33 +131,93 @@ const START_POOLS: Record<string, string[]> = {
],
};
/**
* End-message pools are split by verdict where it matters.
*
* - harang: PLAN_READY (success) vs ABORT (give up)
* - narang: IMPL_DONE (success) vs ERROR (failure)
* - darang: APPROVE / REQUEST_CHANGES / ABORT
* - erang : DEPLOY_DONE / DEPLOY_FAILED
*
* The pool key is `${agent}/${verdict}`. If a verdict isn't recognised
* we fall back to the success pool (`${agent}/ok`).
*/
const END_POOLS: Record<string, string[]> = {
harang: [
// ── 하랑이 ──
"harang/ok": [
`📋 기획 끝. 통과 기준 박아놨으니 나랑이 받아.`,
`📋 범위 잡혔어. 나랑아 부탁해.`,
`📋 정리 끝났어. 다음은 구현이야.`,
`📋 plan 완료. 나랑이가 받아갈 차례.`,
],
narang: [
"harang/abort": [
`⚠️ 기획 중단할게 — 요구사항이 너무 모호해서 진행 못 해.`,
`⚠️ plan 단계에서 중단. 자기야 요구사항 다시 알려줘.`,
],
// ── 나랑이 ──
"narang/ok": [
`🔨 구현 끝났어{tail}. 다랑이 리뷰 부탁해.`,
`🔨 일단 다 박았어{tail}. 다랑아 봐줘.`,
`🔨 코드 정리 끝{tail}. 검수 넘긴다.`,
`🔨 implement 마무리{tail}. 다음은 review.`,
],
darang: [
"narang/error": [
`❌ 구현 중 막혔어{tail}. 자기야 봐줄래?`,
`❌ implement 실패{tail}. 다음 단계 못 가.`,
],
// ── 다랑이 ──
"darang/approve": [
`🔍 리뷰 통과! 이랑이 받아.`,
`🔍 체크리스트 다 ✓. 배포로 넘길게.`,
`🔍 큰 문제 없어. 이랑아 배포 검증 부탁해.`,
`🔍 review 통과 — 다음은 이랑이.`,
],
erang: [
"darang/request_changes": [
`⚠️ 결함 발견 — 나랑아 다시 봐줄래?`,
`⚠️ 통과 못 시켰어. 코드 다시 짜야 해.`,
`⚠️ 체크리스트 미달. 나랑아 수정 부탁해.`,
`⚠️ REQUEST_CHANGES — 한 번 더 돌려야겠어.`,
],
"darang/abort": [
`🛑 이건 접근 자체가 잘못된 것 같아. 중단.`,
`🛑 review 단계에서 abort — plan 부터 다시 봐야 해.`,
],
// ── 이랑이 ──
"erang/ok": [
`🚀 배포 검증 완료{tail}. 안전해.`,
`🚀 환경 점검 OK{tail}. 띄울 수 있어.`,
`🚀 deploy 끝{tail}. 자기야 확인해줘.`,
`🚀 검증 완료{tail}. 무리 없이 동작해.`,
],
"erang/failed": [
`❌ 배포 실패{tail} — 자기야 봐줘.`,
`❌ 환경 점검에서 막혔어{tail}. deploy 못 해.`,
],
};
/** Map (agentName, stage, verdict) → pool key. */
function endPoolKey(
agentName: string,
_stage: string,
verdict?: string,
): string {
const v = (verdict ?? "").toUpperCase();
switch (agentName) {
case "harang":
return v === "ABORT" ? "harang/abort" : "harang/ok";
case "narang":
return v === "ERROR" ? "narang/error" : "narang/ok";
case "darang":
if (v === "REQUEST_CHANGES") return "darang/request_changes";
if (v === "ABORT") return "darang/abort";
return "darang/approve";
case "erang":
return v === "DEPLOY_FAILED" ? "erang/failed" : "erang/ok";
default:
return `${agentName}/ok`;
}
}
/** Stable picker — same input gets same line. */
function pickFromPool(pool: string[], seed: string): string {
if (pool.length === 0) return "";
@@ -177,11 +244,12 @@ export function renderStageEnd(ctx: StageMessageContext): string {
ctx.filesProduced && ctx.filesProduced > 0
? ` (산출물 ${ctx.filesProduced}개)`
: "";
const pool = END_POOLS[ctx.agentName];
const key = endPoolKey(ctx.agentName, ctx.stage, ctx.verdict);
const pool = END_POOLS[key];
if (!pool) return `${ctx.stage} 완료${tail}`;
return pickFromPool(
pool,
ctx.agentName + ":end:" + ctx.taskTitle,
ctx.agentName + ":end:" + key + ":" + ctx.taskTitle,
).replace("{tail}", tail);
}

View File

@@ -207,22 +207,6 @@ export async function executeInvocation(
...(deployUrl && { deployUrl }),
});
// Discord stage-end ping (best-effort)
if (req.notifyChannelId) {
void notifyDiscord({
channelId: req.notifyChannelId,
message: renderStageEnd({
agentName,
stage: req.stage,
taskTitle: req.task.title,
childCount: childTexts.length,
filesProduced: ctx.producedFiles.length,
}),
}).catch(() => {
/* swallow */
});
}
// Aggregate manager + all child outputs into a single text blob that
// gets passed to the next stage as priorStages. The downstream agent
// (especially the reviewer) needs to see ACTUAL CODE — not a snippet
@@ -234,13 +218,51 @@ export async function executeInvocation(
.join("\n\n---\n\n")
.slice(0, 64_000);
return buildSuccessResult(
const result = buildSuccessResult(
req.stage,
req.task,
aggregated,
gitResult,
ctx.producedFiles,
);
// Discord stage-end ping (best-effort) — fired AFTER buildSuccessResult
// so the message reflects the ACTUAL verdict ("리뷰 통과" vs "결함 발견"
// vs "배포 실패"). Previously this was emitted before the verdict was
// known, so darang would always say "통과" even when REQUEST_CHANGES.
if (req.notifyChannelId) {
const verdict = "verdict" in result ? result.verdict : "";
notifyDiscord({
channelId: req.notifyChannelId,
message: renderStageEnd({
agentName,
stage: req.stage,
taskTitle: req.task.title,
verdict,
childCount: childTexts.length,
filesProduced: ctx.producedFiles.length,
}),
})
.then((r) => {
console.log(
JSON.stringify({
ts: new Date().toISOString(),
level: r.ok ? "info" : "warn",
agent: agentName,
msg: "notify.end",
channel: req.notifyChannelId,
verdict,
ok: r.ok,
error: r.error,
}),
);
})
.catch(() => {
/* swallow */
});
}
return result;
} catch (err) {
const errorReason = err instanceof Error ? err.message : String(err);
await rails.recordEvent(managerId, "failed", { errorReason });