From 3332d0d8af07afe230d92bb0f6af9f0647826fbc Mon Sep 17 00:00:00 2001 From: Christopher Maher Date: Tue, 14 Jul 2026 15:45:24 -0700 Subject: [PATCH] feat(foreman): opt-in coder escalation on capability-wall failures (#1108) Add Workload.spec.escalateOnFailure (default false, preserving today's NO-GO-only escalation). When set and escalationCoderAgentRef is configured, a terminal STUCK-LOOP-DETECTED, INCOMPLETE, or ERROR from the base coder also re-attempts the issue on the escalation coder, so an unattended heterogeneous fleet keeps making progress instead of stalling for a human. ALREADY-RESOLVED and NEEDS-VERIFICATION never escalate; a distinct condition reason surfaces why the escalation fired. Fixes #1108 Signed-off-by: Christopher Maher --- api/foreman/v1alpha1/workload_types.go | 13 ++ api/foreman/v1alpha1/zz_generated.deepcopy.go | 5 + charts/foreman/templates/crds/workloads.yaml | 13 ++ .../bases/foreman.llmkube.dev_workloads.yaml | 13 ++ .../controller/workload_coder_escalation.go | 55 ++++- .../workload_coder_escalation_test.go | 211 +++++++++++++++++- 6 files changed, 297 insertions(+), 13 deletions(-) diff --git a/api/foreman/v1alpha1/workload_types.go b/api/foreman/v1alpha1/workload_types.go index 932f1ced..816d0c85 100644 --- a/api/foreman/v1alpha1/workload_types.go +++ b/api/foreman/v1alpha1/workload_types.go @@ -194,9 +194,22 @@ type WorkloadSpec struct { // Does NOT trigger on STUCK-LOOP-DETECTED, a model-decided // INCOMPLETE, or ERROR: those are scope/harness failures a larger, // slower model will not fix. Leave unset to disable escalation. + // When EscalateOnFailure is set to true, the escalation coder also + // triggers on STUCK-LOOP-DETECTED, INCOMPLETE, and ERROR outcomes + // from the base coder (in addition to the default NO-GO capability + // failures). // +optional EscalationCoderAgentRef *corev1.LocalObjectReference `json:"escalationCoderAgentRef,omitempty"` + // EscalateOnFailure, when true, additionally re-attempts the issue on + // EscalationCoderAgentRef for a terminal STUCK-LOOP-DETECTED, INCOMPLETE, + // or ERROR from the base coder (not just a capability NO-GO). Requires + // EscalationCoderAgentRef to be set. Default false preserves the + // NO-GO-only behavior. ALREADY-RESOLVED and NEEDS-VERIFICATION never + // escalate. + // +optional + EscalateOnFailure *bool `json:"escalateOnFailure,omitempty"` + // AllowOverwrite lets this Workload's coder replace a stale remote // ref for its own foreman/* branch (force-with-lease compare-and-swap) // instead of failing non-fast-forward. Opt-in — #573 deliberately diff --git a/api/foreman/v1alpha1/zz_generated.deepcopy.go b/api/foreman/v1alpha1/zz_generated.deepcopy.go index 4a40fc8b..b51c7206 100644 --- a/api/foreman/v1alpha1/zz_generated.deepcopy.go +++ b/api/foreman/v1alpha1/zz_generated.deepcopy.go @@ -1166,6 +1166,11 @@ func (in *WorkloadSpec) DeepCopyInto(out *WorkloadSpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.EscalateOnFailure != nil { + in, out := &in.EscalateOnFailure, &out.EscalateOnFailure + *out = new(bool) + **out = **in + } if in.MaxReviewIterations != nil { in, out := &in.MaxReviewIterations, &out.MaxReviewIterations *out = new(int32) diff --git a/charts/foreman/templates/crds/workloads.yaml b/charts/foreman/templates/crds/workloads.yaml index cf038f8a..0baba307 100644 --- a/charts/foreman/templates/crds/workloads.yaml +++ b/charts/foreman/templates/crds/workloads.yaml @@ -113,6 +113,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + escalateOnFailure: + description: |- + EscalateOnFailure, when true, additionally re-attempts the issue on + EscalationCoderAgentRef for a terminal STUCK-LOOP-DETECTED, INCOMPLETE, + or ERROR from the base coder (not just a capability NO-GO). Requires + EscalationCoderAgentRef to be set. Default false preserves the + NO-GO-only behavior. ALREADY-RESOLVED and NEEDS-VERIFICATION never + escalate. + type: boolean escalationCoderAgentRef: description: |- EscalationCoderAgentRef is the optional coder escalation tier: a @@ -134,6 +143,10 @@ spec: Does NOT trigger on STUCK-LOOP-DETECTED, a model-decided INCOMPLETE, or ERROR: those are scope/harness failures a larger, slower model will not fix. Leave unset to disable escalation. + When EscalateOnFailure is set to true, the escalation coder also + triggers on STUCK-LOOP-DETECTED, INCOMPLETE, and ERROR outcomes + from the base coder (in addition to the default NO-GO capability + failures). properties: name: default: "" diff --git a/config/crd/bases/foreman.llmkube.dev_workloads.yaml b/config/crd/bases/foreman.llmkube.dev_workloads.yaml index b2c5c95b..435488b4 100644 --- a/config/crd/bases/foreman.llmkube.dev_workloads.yaml +++ b/config/crd/bases/foreman.llmkube.dev_workloads.yaml @@ -112,6 +112,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + escalateOnFailure: + description: |- + EscalateOnFailure, when true, additionally re-attempts the issue on + EscalationCoderAgentRef for a terminal STUCK-LOOP-DETECTED, INCOMPLETE, + or ERROR from the base coder (not just a capability NO-GO). Requires + EscalationCoderAgentRef to be set. Default false preserves the + NO-GO-only behavior. ALREADY-RESOLVED and NEEDS-VERIFICATION never + escalate. + type: boolean escalationCoderAgentRef: description: |- EscalationCoderAgentRef is the optional coder escalation tier: a @@ -133,6 +142,10 @@ spec: Does NOT trigger on STUCK-LOOP-DETECTED, a model-decided INCOMPLETE, or ERROR: those are scope/harness failures a larger, slower model will not fix. Leave unset to disable escalation. + When EscalateOnFailure is set to true, the escalation coder also + triggers on STUCK-LOOP-DETECTED, INCOMPLETE, and ERROR outcomes + from the base coder (in addition to the default NO-GO capability + failures). properties: name: default: "" diff --git a/internal/foreman/controller/workload_coder_escalation.go b/internal/foreman/controller/workload_coder_escalation.go index dc771236..6078d425 100644 --- a/internal/foreman/controller/workload_coder_escalation.go +++ b/internal/foreman/controller/workload_coder_escalation.go @@ -171,6 +171,34 @@ func shouldEscalateCoder( return false } +// shouldEscalateCoderOnFailure is the opt-in escalation trigger for +// capability-wall failures that are NOT pure capability NO-GOs. When +// EscalateOnFailure is set on the Workload, these outcomes also +// escalate to the escalation coder: STUCK-LOOP-DETECTED (harness +// detected a stuck loop), INCOMPLETE (model gave up / ran out of turns), +// and ERROR (harness error). ALREADY-RESOLVED and NEEDS-VERIFICATION +// never escalate even when the flag is set. +func shouldEscalateCoderOnFailure( + verdict foremanv1alpha1.AgenticTaskVerdict, topOutcome string, +) bool { + if verdict == foremanv1alpha1.AgenticTaskVerdictNoGo && topOutcome == alreadyResolvedOutcome { + return false // #970: already-resolved is not a failure + } + if verdict == foremanv1alpha1.AgenticTaskVerdictNoGo && topOutcome == needsVerificationOutcome { + return false // #1033: a larger model can't reach the ground truth either + } + if topOutcome == "STUCK-LOOP-DETECTED" { + return true + } + if topOutcome == "ERROR" { + return true + } + if verdict == foremanv1alpha1.AgenticTaskVerdictIncomplete && topOutcome == "MODEL-DECIDED" { + return true + } + return false +} + // escalationHintPrompt renders the PromptPrefix given to the escalation // coder. It carries the prior model's summary as a hint, framed so the // larger model does not treat the failed attempt's conclusions as @@ -206,13 +234,15 @@ func escalationHintPrompt(priorSummary string) string { // Pure: no API calls, no status writes. The caller owns MaxTasks // accounting, sovereignty filtering, and creation. Issue-batch mode // only (the caller guards against explicit Pipeline mode). +// Returns a reason string ("BaseCoderCapabilityFailure" or +// "BaseCoderFailureEscalation") for the condition Reason field. func coderEscalationSteps( w *foremanv1alpha1.Workload, children []foremanv1alpha1.AgenticTask, -) (steps []foremanv1alpha1.PipelineStep, escalated []int32) { +) (steps []foremanv1alpha1.PipelineStep, escalated []int32, reason string) { if w.Spec.EscalationCoderAgentRef == nil || w.Spec.VerifierAgentRef == nil || len(w.Spec.Issues) == 0 { - return nil, nil + return nil, nil, "" } // Index base code tasks and existing -esc tasks by step label. @@ -243,13 +273,23 @@ func coderEscalationSteps( continue // not terminal yet } verdict, topOutcome, modelOutcome := coderTerminalOutcome(base) - if !shouldEscalateCoder(verdict, topOutcome, modelOutcome) { + if !shouldEscalateCoder(verdict, topOutcome, modelOutcome) && + !(w.Spec.EscalateOnFailure != nil && *w.Spec.EscalateOnFailure && + shouldEscalateCoderOnFailure(verdict, topOutcome)) { continue } if _, exists := existingEsc[escCodeStep]; exists { continue // already escalated this issue } + // Determine the reason for this escalation. + escReason := "BaseCoderCapabilityFailure" + if !shouldEscalateCoder(verdict, topOutcome, modelOutcome) && + w.Spec.EscalateOnFailure != nil && *w.Spec.EscalateOnFailure && + shouldEscalateCoderOnFailure(verdict, topOutcome) { + escReason = "BaseCoderFailureEscalation" + } + branch := fmt.Sprintf("foreman/%s/issue-%d-esc", w.Name, n) steps = append(steps, foremanv1alpha1.PipelineStep{ @@ -297,8 +337,11 @@ func coderEscalationSteps( }) } escalated = append(escalated, n) + // Use the last seen reason (all issues in one pass share the + // same escalation mode; the reason is uniform across the batch). + reason = escReason } - return steps, escalated + return steps, escalated, reason } // emitCoderEscalations is the coder-side second-pass emission hook, @@ -321,7 +364,7 @@ func (r *WorkloadReconciler) emitCoderEscalations( return children, nil } - steps, escalated := coderEscalationSteps(w, children) + steps, escalated, escReason := coderEscalationSteps(w, children) if len(steps) == 0 { return children, nil } @@ -372,7 +415,7 @@ func (r *WorkloadReconciler) emitCoderEscalations( setCondition(&w.Status.Conditions, metav1.Condition{ Type: conditionTypeCoderEscalationTriggered, Status: metav1.ConditionTrue, - Reason: "BaseCoderCapabilityFailure", + Reason: escReason, Message: msg, LastTransitionTime: now, }) diff --git a/internal/foreman/controller/workload_coder_escalation_test.go b/internal/foreman/controller/workload_coder_escalation_test.go index 8182457b..1dfb8d3f 100644 --- a/internal/foreman/controller/workload_coder_escalation_test.go +++ b/internal/foreman/controller/workload_coder_escalation_test.go @@ -70,6 +70,33 @@ func TestShouldEscalateCoder(t *testing.T) { } } +func TestShouldEscalateCoderOnFailure(t *testing.T) { + cases := []struct { + name string + verdict foremanv1alpha1.AgenticTaskVerdict + topOutcome string + modelOutcome string + want bool + }{ + {"STUCK-LOOP-DETECTED", foremanv1alpha1.AgenticTaskVerdictIncomplete, "STUCK-LOOP-DETECTED", "", true}, + {"ERROR", foremanv1alpha1.AgenticTaskVerdictIncomplete, "ERROR", "", true}, + {"INCOMPLETE (model gave up)", foremanv1alpha1.AgenticTaskVerdictIncomplete, "MODEL-DECIDED", "", true}, + {"NO-GO + ALREADY-RESOLVED (never escalate)", foremanv1alpha1.AgenticTaskVerdictNoGo, "ALREADY-RESOLVED", "", false}, + {"NO-GO + NEEDS-VERIFICATION (never escalate)", foremanv1alpha1.AgenticTaskVerdictNoGo, "NEEDS-VERIFICATION", "", false}, + {"NO-GO + MODEL-DECIDED (capability failure, not opt-in)", foremanv1alpha1.AgenticTaskVerdictNoGo, "MODEL-DECIDED", "", false}, + {"GO", foremanv1alpha1.AgenticTaskVerdictGo, "", "", false}, + {"NO-CHANGES", foremanv1alpha1.AgenticTaskVerdictNoGo, "NO-CHANGES", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldEscalateCoderOnFailure(tc.verdict, tc.topOutcome); got != tc.want { + t.Errorf("shouldEscalateCoderOnFailure(%s,%q)=%v want %v", + tc.verdict, tc.topOutcome, got, tc.want) + } + }) + } +} + func TestCoderTerminalOutcome_ReadsNestedGateOutcome(t *testing.T) { task := &foremanv1alpha1.AgenticTask{} task.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictIncomplete @@ -98,7 +125,7 @@ func TestCoderEscalationSteps_EmitsOnNoGo(t *testing.T) { code.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictNoGo code.Status.Result = resultRaw("MODEL-DECIDED", "", "could not solve: fuzzy front-runs anchor", "") - steps, escalated := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{code}) + steps, escalated, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{code}) if len(steps) != 2 { t.Fatalf("want 2 steps (code-esc+verify-esc), got %d", len(steps)) } @@ -155,7 +182,7 @@ func TestCoderEscalationSteps_EmitsReviewerStepsOnEscBranch(t *testing.T) { w := newWorkload() w.Spec.ReviewerAgentRefs = []corev1.LocalObjectReference{{Name: "reviewer-a"}} - steps, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{newBase()}) + steps, _, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{newBase()}) // code-esc + verify-esc + one review-esc. if len(steps) != 3 { t.Fatalf("want 3 steps (code+verify+review esc), got %d: %+v", len(steps), steps) @@ -190,7 +217,7 @@ func TestCoderEscalationSteps_EmitsReviewerStepsOnEscBranch(t *testing.T) { no := false w.Spec.OpenPullRequest = &no - steps, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{newBase()}) + steps, _, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{newBase()}) rev, ok := findStep(steps, "review-944-esc-0") if !ok { t.Fatalf("review-944-esc-0 not emitted: %+v", steps) @@ -200,10 +227,10 @@ func TestCoderEscalationSteps_EmitsReviewerStepsOnEscBranch(t *testing.T) { } }) - t.Run("no reviewers -> code+verify only (backward-compat)", func(t *testing.T) { + t.Run("no reviewers -\u003e code+verify only (backward-compat)", func(t *testing.T) { w := newWorkload() // ReviewerAgentRefs left nil - steps, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{newBase()}) + steps, _, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{newBase()}) if len(steps) != 2 { t.Fatalf("want 2 steps (code+verify only), got %d: %+v", len(steps), steps) } @@ -239,7 +266,7 @@ func TestCoderEscalationSteps_SkipsStuckLoopAndExisting(t *testing.T) { esc944.Name = "wl-code-944-esc" esc944.Labels = map[string]string{labelStep: "code-944-esc"} - steps, escalated := coderEscalationSteps(w, + steps, escalated, reason := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{c921, c944, esc944}) if len(steps) != 0 { t.Errorf("want no steps (921 not eligible, 944 already escalated), got %d: %+v", len(steps), steps) @@ -247,6 +274,176 @@ func TestCoderEscalationSteps_SkipsStuckLoopAndExisting(t *testing.T) { if len(escalated) != 0 { t.Errorf("want none escalated, got %v", escalated) } + if reason != "" { + t.Errorf("want empty reason when no escalation, got %q", reason) + } +} + +// TestCoderEscalationSteps_EscalateOnFailure_True tests that when +// EscalateOnFailure is set to true, STUCK-LOOP-DETECTED, INCOMPLETE, +// and ERROR outcomes also trigger escalation. +func TestCoderEscalationSteps_EscalateOnFailure_True(t *testing.T) { + ref := corev1.LocalObjectReference{Name: "coder-qwopus"} + w := &foremanv1alpha1.Workload{} + w.Name = "wl" + w.Spec.Repo = "defilantech/LLMKube" + w.Spec.Issues = []int32{921, 944} + w.Spec.VerifierAgentRef = &corev1.LocalObjectReference{Name: "gate"} + w.Spec.EscalationCoderAgentRef = &ref + trueVal := true + w.Spec.EscalateOnFailure = &trueVal + + c921 := foremanv1alpha1.AgenticTask{} + c921.Name = "wl-code-921" + c921.Labels = map[string]string{labelStep: "code-921"} + c921.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c921.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictIncomplete + c921.Status.Result = resultRaw("STUCK-LOOP-DETECTED", "", "stuck in loop", "") + + c944 := foremanv1alpha1.AgenticTask{} + c944.Name = "wl-code-944" + c944.Labels = map[string]string{labelStep: "code-944"} + c944.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c944.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictIncomplete + c944.Status.Result = resultRaw("ERROR", "", "harness error", "") + + steps, escalated, reason := coderEscalationSteps(w, + []foremanv1alpha1.AgenticTask{c921, c944}) + if len(steps) != 4 { + t.Errorf("want 4 steps (2 code-esc + 2 verify-esc), got %d: %+v", len(steps), steps) + } + if len(escalated) != 2 || escalated[0] != 921 || escalated[1] != 944 { + t.Errorf("want 2 issues escalated, got %v", escalated) + } + if reason != "BaseCoderFailureEscalation" { + t.Errorf("want reason %q, got %q", "BaseCoderFailureEscalation", reason) + } +} + +// TestCoderEscalationSteps_EscalateOnFailure_False tests that when +// EscalateOnFailure is explicitly false, STUCK-LOOP-DETECTED, +// INCOMPLETE, and ERROR outcomes do NOT trigger escalation. +func TestCoderEscalationSteps_EscalateOnFailure_False(t *testing.T) { + ref := corev1.LocalObjectReference{Name: "coder-qwopus"} + w := &foremanv1alpha1.Workload{} + w.Name = "wl" + w.Spec.Repo = "defilantech/LLMKube" + w.Spec.Issues = []int32{921, 944} + w.Spec.VerifierAgentRef = &corev1.LocalObjectReference{Name: "gate"} + w.Spec.EscalationCoderAgentRef = &ref + falseVal := false + w.Spec.EscalateOnFailure = &falseVal + + c921 := foremanv1alpha1.AgenticTask{} + c921.Name = "wl-code-921" + c921.Labels = map[string]string{labelStep: "code-921"} + c921.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c921.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictIncomplete + c921.Status.Result = resultRaw("STUCK-LOOP-DETECTED", "", "stuck in loop", "") + + c944 := foremanv1alpha1.AgenticTask{} + c944.Name = "wl-code-944" + c944.Labels = map[string]string{labelStep: "code-944"} + c944.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c944.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictIncomplete + c944.Status.Result = resultRaw("ERROR", "", "harness error", "") + + steps, escalated, reason := coderEscalationSteps(w, + []foremanv1alpha1.AgenticTask{c921, c944}) + if len(steps) != 0 { + t.Errorf("want no steps (EscalateOnFailure=false), got %d: %+v", len(steps), steps) + } + if len(escalated) != 0 { + t.Errorf("want none escalated, got %v", escalated) + } + if reason != "" { + t.Errorf("want empty reason when no escalation, got %q", reason) + } +} + +// TestCoderEscalationSteps_EscalateOnFailure_NeverEscalate tests that +// ALREADY-RESOLVED and NEEDS-VERIFICATION never escalate even when +// EscalateOnFailure is true. +func TestCoderEscalationSteps_EscalateOnFailure_NeverEscalate(t *testing.T) { + ref := corev1.LocalObjectReference{Name: "coder-qwopus"} + w := &foremanv1alpha1.Workload{} + w.Name = "wl" + w.Spec.Repo = "defilantech/LLMKube" + w.Spec.Issues = []int32{921, 944} + w.Spec.VerifierAgentRef = &corev1.LocalObjectReference{Name: "gate"} + w.Spec.EscalationCoderAgentRef = &ref + trueVal := true + w.Spec.EscalateOnFailure = &trueVal + + c921 := foremanv1alpha1.AgenticTask{} + c921.Name = "wl-code-921" + c921.Labels = map[string]string{labelStep: "code-921"} + c921.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c921.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictNoGo + c921.Status.Result = resultRaw("ALREADY-RESOLVED", "", "already done", "") + + c944 := foremanv1alpha1.AgenticTask{} + c944.Name = "wl-code-944" + c944.Labels = map[string]string{labelStep: "code-944"} + c944.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c944.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictNoGo + c944.Status.Result = resultRaw("NEEDS-VERIFICATION", "", "ungroundable fact", "") + + steps, escalated, reason := coderEscalationSteps(w, + []foremanv1alpha1.AgenticTask{c921, c944}) + if len(steps) != 0 { + t.Errorf("want no steps (ALREADY-RESOLVED and NEEDS-VERIFICATION never escalate), got %d: %+v", len(steps), steps) + } + if len(escalated) != 0 { + t.Errorf("want none escalated, got %v", escalated) + } + if reason != "" { + t.Errorf("want empty reason when no escalation, got %q", reason) + } +} + +// TestCoderEscalationSteps_EscalateOnFailure_Mixed tests that when +// EscalateOnFailure is true, a mix of capability failures and +// opt-in failures all escalate with the correct reason. +func TestCoderEscalationSteps_EscalateOnFailure_Mixed(t *testing.T) { + ref := corev1.LocalObjectReference{Name: "coder-qwopus"} + w := &foremanv1alpha1.Workload{} + w.Name = "wl" + w.Spec.Repo = "defilantech/LLMKube" + w.Spec.Issues = []int32{921, 944} + w.Spec.VerifierAgentRef = &corev1.LocalObjectReference{Name: "gate"} + w.Spec.EscalationCoderAgentRef = &ref + trueVal := true + w.Spec.EscalateOnFailure = &trueVal + + // Issue 921: capability failure (should use BaseCoderCapabilityFailure) + c921 := foremanv1alpha1.AgenticTask{} + c921.Name = "wl-code-921" + c921.Labels = map[string]string{labelStep: "code-921"} + c921.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c921.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictNoGo + c921.Status.Result = resultRaw("MODEL-DECIDED", "", "could not solve", "") + + // Issue 944: opt-in failure (should use BaseCoderFailureEscalation) + c944 := foremanv1alpha1.AgenticTask{} + c944.Name = "wl-code-944" + c944.Labels = map[string]string{labelStep: "code-944"} + c944.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded + c944.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictIncomplete + c944.Status.Result = resultRaw("STUCK-LOOP-DETECTED", "", "stuck in loop", "") + + steps, escalated, reason := coderEscalationSteps(w, + []foremanv1alpha1.AgenticTask{c921, c944}) + if len(steps) != 4 { + t.Errorf("want 4 steps (2 code-esc + 2 verify-esc), got %d: %+v", len(steps), steps) + } + if len(escalated) != 2 || escalated[0] != 921 || escalated[1] != 944 { + t.Errorf("want 2 issues escalated, got %v", escalated) + } + // The reason is the last seen escalation reason (opt-in path) + if reason != "BaseCoderFailureEscalation" { + t.Errorf("want reason %q, got %q", "BaseCoderFailureEscalation", reason) + } } // TestIsAlreadyResolvedCoder verifies the helper that downstream @@ -322,7 +519,7 @@ func TestCoderEscalationSteps_OffWhenUnset(t *testing.T) { c.Status.Phase = foremanv1alpha1.AgenticTaskPhaseSucceeded c.Status.Verdict = foremanv1alpha1.AgenticTaskVerdictNoGo c.Status.Result = resultRaw("MODEL-DECIDED", "", "bailed", "") - steps, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{c}) + steps, _, _ := coderEscalationSteps(w, []foremanv1alpha1.AgenticTask{c}) if len(steps) != 0 { t.Errorf("feature must be off when EscalationCoderAgentRef is nil, got %d steps", len(steps)) }