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",