From fb90abc8e306e6551251b53de179d7c63a2e891e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=9E=91=EC=9D=B4?= Date: Sat, 11 Apr 2026 15:35:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(runner):=20review-loop=20=EC=86=8C=EC=A7=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20escalated=20=EB=8F=84=EB=8B=AC=20=EC=8B=9C?= =?UTF-8?q?=20recordEscalation/notify=20=EB=88=84=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자기야 검증: forced REQUEST_CHANGES 로 escalation 까지 갔는데 디스코드 알림이 안 떨어졌음. 로그 분석 결과 recordEscalation() 호출 자체가 0회. 원인: runner.ts 의 main loop 가 recordEscalation 을 ERROR 이벤트의 non-retryable 분기에서만 호출. 그런데 review-loop 소진 + replan 소진으로 FSM 이 자체적으로 escalated 로 전이한 경우는 ERROR 가 아니라 REQUEST_CHANGES 이벤트 경로라서 그 분기를 안 탐. 결과: escalation row 안 만들어지고 notifier 도 안 호출됨. 수정: - escalationRecorded flag 추가, ERROR non-retryable 분기에서 true 로 세팅 - lastActiveStage 를 매 iteration 마다 트래킹 - main loop 종료 후 result.state === "escalated" && !escalationRecorded 면 post-loop 에서 recordEscalation() + emit({type:"escalated"}) 호출 - stage 는 lastActiveStage (review-loop 소진의 경우 보통 "review"), attempts 는 context.replanCount, reason 은 context.lastError 검증 예정: 다음 forced 테스트에서 디스코드 채널에 escalation SOS 알림 떨어짐. 113 테스트 그대로 통과. --- src/orchestrator/runner.ts | 45 +++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/orchestrator/runner.ts b/src/orchestrator/runner.ts index 35a0355..085c7b0 100644 --- a/src/orchestrator/runner.ts +++ b/src/orchestrator/runner.ts @@ -136,6 +136,8 @@ export async function runPipeline(opts: RunOptions): Promise { }); let transitions = 1; + let escalationRecorded = false; + let lastActiveStage: "plan" | "implement" | "review" | "deploy" = "plan"; const TERMINAL: PipelineState[] = ["done", "escalated", "aborted"]; // Accumulate stage outputs so each stage can see what the previous ones produced. @@ -159,6 +161,7 @@ export async function runPipeline(opts: RunOptions): Promise { log.warn({ state: result.state }, "Non-active state encountered, stopping"); break; } + lastActiveStage = stage; const transport = opts.transports.get(stage); if (!transport) { @@ -247,6 +250,7 @@ export async function runPipeline(opts: RunOptions): Promise { }, opts.notifier, ); + escalationRecorded = true; emit({ type: "escalated", pipelineId, @@ -279,7 +283,46 @@ export async function runPipeline(opts: RunOptions): Promise { transitions, }); } else if (result.state === "escalated") { - // Escalated emit already fired at the failure site; no extra event. + // FSM can reach `escalated` two ways: + // 1. ERROR (non-retryable) — recordEscalation was called inline + // and escalationRecorded was set true. + // 2. REQUEST_CHANGES exhaustion (review-loop / replan budget) — + // that's a normal handoff event, not an ERROR, so the inline + // branch above never runs. Catch it here. + if (!escalationRecorded) { + const reason = String( + result.context.lastError ?? "Pipeline escalated", + ); + const replanCount = (result.context as { replanCount?: number }) + .replanCount ?? 0; + try { + await recordEscalation( + { + pipelineId, + stage: lastActiveStage, + reason, + attempts: replanCount, + contextSnapshot: result.context as unknown as Record< + string, + unknown + >, + }, + opts.notifier, + ); + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err) }, + "post-loop recordEscalation failed", + ); + } + emit({ + type: "escalated", + pipelineId, + stage: lastActiveStage, + reason, + attempts: replanCount, + }); + } } else { emit({ type: "failed",