fix(runner): review-loop 소진으로 escalated 도달 시 recordEscalation/notify 누락

자기야 검증: 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 테스트 그대로 통과.
This commit is contained in:
2026-04-11 15:35:08 +09:00
parent fc646894d9
commit fb90abc8e3

View File

@@ -136,6 +136,8 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
});
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<RunResult> {
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<RunResult> {
},
opts.notifier,
);
escalationRecorded = true;
emit({
type: "escalated",
pipelineId,
@@ -279,7 +283,46 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
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",