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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user