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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user