Skip to content

OLS-3719, OTA-2090: Check needsRevision() in terminal phases before early return#366

Open
jhadvig wants to merge 4 commits into
openshift:mainfrom
jhadvig:fix-revision-terminal-phases
Open

OLS-3719, OTA-2090: Check needsRevision() in terminal phases before early return#366
jhadvig wants to merge 4 commits into
openshift:mainfrom
jhadvig:fix-revision-terminal-phases

Conversation

@jhadvig

@jhadvig jhadvig commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a bug where patching spec.revisionFeedback on a completed AgenticRun has no effect. The "Re-analyse" button in the cluster-update-console-plugin triggers this patch, but the operator never re-enters the analysis loop.

Jira: https://redhat.atlassian.net/browse/OTA-2090

Root cause

The reconciler's first switch block handles terminal phases before the suspension guard. The NoActionRequired phase correctly guards its early return with !needsRevision(&run), falling through to the phase routing switch when a revision is needed. However, the Completed, Denied, Escalated, and EmergencyStopped phases return unconditionally without checking needsRevision():

// Before (lines 129-142):
case AgenticRunPhaseCompleted, AgenticRunPhaseDenied, ...:
    if hasSandboxClaims(&run) { ... }
    if r.Audit != nil { ... }
    return ctrl.Result{}, nil  // ← always returns, never checks revision

Fix

Two changes in controller/agenticrun/reconciler.go:

  1. First switch (terminal phases): wrap the early return in if !needsRevision(&run), mirroring the existing NoActionRequired pattern. When revision is needed, execution falls through to the phase routing switch.

  2. Second switch (phase routing): add Completed/Denied/Escalated/EmergencyStopped to the NoActionRequired case, which already calls handleRevision() when needsRevision() returns true.

How to reproduce

  1. Wait for an AgenticRun to complete via advisory-only path
  2. Patch spec.revisionFeedback:
    oc patch agenticrun <name> -n openshift-lightspeed \
      --type=json -p '[{"op":"replace","path":"/spec/revisionFeedback","value":"re-analyse"}]'
    
  3. Observe metadata.generation increments but Analyzed.observedGeneration stays stale
  4. Operator logs show no reconciliation after the patch

Evidence

$ oc get agenticrun ota-5-0-0-ec-4-to-5-0-8 -o jsonpath='{.metadata.generation}'
2

$ oc get agenticrun ota-5-0-0-ec-4-to-5-0-8 \
    -o jsonpath='{.status.conditions[?(@.type=="Analyzed")].observedGeneration}'
1

# needsRevision() would return true (2 > 1), but reconciler never evaluates it

The reconciler's terminal phase handler (Completed, Denied, Escalated,
EmergencyStopped) returned immediately without checking needsRevision().
This meant patching spec.revisionFeedback on a completed AgenticRun had
no effect — the operator never re-entered the analysis loop.

The NoActionRequired phase already had the correct pattern: guard the
early return with !needsRevision() and fall through to the phase routing
switch where handleRevision() is called. This commit applies the same
pattern to the other terminal phases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@openshift-ci-robot openshift-ci-robot added the jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. label Jul 23, 2026
@openshift-ci-robot

openshift-ci-robot commented Jul 23, 2026

Copy link
Copy Markdown

@jhadvig: This pull request references OTA-2090 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the bug to target the "5.0.0" version, but no target version was set.

Details

In response to this:

Summary

Fixes a bug where patching spec.revisionFeedback on a completed AgenticRun has no effect. The "Re-analyse" button in the cluster-update-console-plugin triggers this patch, but the operator never re-enters the analysis loop.

Jira: https://redhat.atlassian.net/browse/OTA-2090

Root cause

The reconciler's first switch block handles terminal phases before the suspension guard. The NoActionRequired phase correctly guards its early return with !needsRevision(&run), falling through to the phase routing switch when a revision is needed. However, the Completed, Denied, Escalated, and EmergencyStopped phases return unconditionally without checking needsRevision():

// Before (lines 129-142):
case AgenticRunPhaseCompleted, AgenticRunPhaseDenied, ...:
   if hasSandboxClaims(&run) { ... }
   if r.Audit != nil { ... }
   return ctrl.Result{}, nil  // ← always returns, never checks revision

Fix

Two changes in controller/agenticrun/reconciler.go:

  1. First switch (terminal phases): wrap the early return in if !needsRevision(&run), mirroring the existing NoActionRequired pattern. When revision is needed, execution falls through to the phase routing switch.

  2. Second switch (phase routing): add Completed/Denied/Escalated/EmergencyStopped to the NoActionRequired case, which already calls handleRevision() when needsRevision() returns true.

How to reproduce

  1. Wait for an AgenticRun to complete via advisory-only path
  2. Patch spec.revisionFeedback:
oc patch agenticrun <name> -n openshift-lightspeed \
  --type=json -p '[{"op":"replace","path":"/spec/revisionFeedback","value":"re-analyse"}]'
  1. Observe metadata.generation increments but Analyzed.observedGeneration stays stale
  2. Operator logs show no reconciliation after the patch

Evidence

$ oc get agenticrun ota-5-0-0-ec-4-to-5-0-8 -o jsonpath='{.metadata.generation}'
2

$ oc get agenticrun ota-5-0-0-ec-4-to-5-0-8 \
   -o jsonpath='{.status.conditions[?(@.type=="Analyzed")].observedGeneration}'
1

# needsRevision() would return true (2 > 1), but reconciler never evaluates it

🤖 Generated with Claude Code

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Completed AgenticRun reconciliation now routes revision-needed runs back to Proposed before terminal cleanup. Sandbox release and audit cleanup remain conditional, with regression coverage for the completed-run revision path.

Changes

Completed Run Revision

Layer / File(s) Summary
Revision-aware Completed routing
controller/agenticrun/reconciler.go
Completed runs use execution and revision guards before routing to handleRevision or performing terminal cleanup.
Completed-phase revision coverage
controller/agenticrun/handlers_test.go
The test verifies that revising a Completed advisory run returns it to Proposed and updates Analyzed.ObservedGeneration to the current generation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main fix: checking needsRevision() in terminal phases before returning.
Description check ✅ Passed The description directly matches the changeset and explains the revision-in-terminal-phases bug and fix.

Comment @coderabbitai help to get the list of available commands.

@openshift-ci
openshift-ci Bot requested review from harche and onmete July 23, 2026 07:42
@openshift-ci

openshift-ci Bot commented Jul 23, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign harche for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

Verify that patching revisionFeedback on a completed advisory-only
AgenticRun triggers re-analysis. This is the scenario that was broken
before the needsRevision() guard was added to terminal phases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@controller/agenticrun/handlers_test.go`:
- Around line 734-736: Update the assertion in the Completed-phase revision test
to require analyzed.ObservedGeneration to equal p.Generation, while retaining
the nil-condition guard and failure message. This verifies handleRevision
processed the current generation rather than accepting any nonzero stale value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d618756f-0f3a-4a40-993c-667ec5f56a53

📥 Commits

Reviewing files that changed from the base of the PR and between 1bee693 and 6180ad2.

📒 Files selected for processing (1)
  • controller/agenticrun/handlers_test.go
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • openshift/lightspeed-agentic-sandbox (manual)

Comment thread controller/agenticrun/handlers_test.go Outdated

@harche harche left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough writeup. The root cause analysis is spot on, and the Completed path (the actual OTA-2090 scenario) is correct and covered by a good test.

However, I would ask that we restrict the new fall through to Completed only and leave Denied, Escalated, and EmergencyStopped out of both switches. Two reasons:

1. Two of the four phases are mechanically broken. handleRevision() only clears the Executed/Verified/Escalated conditions. It never removes Denied or EmergencyStopped, and DerivePhase() checks those two first. So a revision from Denied or EmergencyStopped runs the full reanalysis (a real LLM call, a new AnalysisResult CR, Analyzed flips to RevisionComplete) and then the run snaps straight back to the same terminal phase. The revised analysis never surfaces as Proposed. I verified this with a quick test against this branch (details in the inline comment).

2. It matches the documented contract of the field. revisionFeedback is defined as feedback on the analysis:

// revisionFeedback is the user's free-text feedback requesting changes
// to the analysis. Patching this field bumps metadata.generation, which
// the operator detects (generation > observedGeneration) and triggers
// re-analysis with the feedback appended to the original request.
//
// Mutable: this is the only mutable spec field. All other spec fields
// are immutable via CEL rules, so generation changes signal revision.

A user denial or the emergency kill switch is not feedback about the analysis, and revision should not be a path to resurrect a killed run. If we later want "deny with feedback, then reanalyse," that is a deliberate feature: handleRevision() would need to clear the Denied condition too, plus tests.

Notes that do not block:

  • The comment at reconciler.go:150 saying that only runs in nonterminal phases reach the suspension guard is now stale: a terminal run that needs revision reaches it too. Worth updating while you are here.
  • Narrow race worth a follow up (the shape predates this PR, but this PR widens the exposure): if revisionFeedback lands before the terminal reconcile has released sandboxes, the release at the top of the switch is skipped, and handleRevision() calls resetExecutionAndVerification() which wipes the claim names from status. After that, nothing (including the deletion finalizer) can find them to release. The NoActionRequired path never had this exposure since analysis only runs carry no execution or verification sandboxes.

Comment thread controller/agenticrun/reconciler.go Outdated
Comment on lines +130 to +133
agenticv1alpha1.AgenticRunPhaseDenied,
agenticv1alpha1.AgenticRunPhaseEscalated,
agenticv1alpha1.AgenticRunPhaseEmergencyStopped:
if hasSandboxClaims(&run) {
if err := r.Agent.ReleaseSandboxes(ctx, &run); err != nil {
log.Error(err, "sandbox cleanup failed at terminal phase")
if !needsRevision(&run) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest restricting this to AgenticRunPhaseCompleted only, keeping Denied, Escalated, and EmergencyStopped on the unconditional early return.

For Denied and EmergencyStopped the fall through cannot actually work: handleRevision() only removes these three conditions:

base := run.DeepCopy()
meta.RemoveStatusCondition(&run.Status.Conditions, agenticv1alpha1.AgenticRunConditionExecuted)
meta.RemoveStatusCondition(&run.Status.Conditions, agenticv1alpha1.AgenticRunConditionVerified)
meta.RemoveStatusCondition(&run.Status.Conditions, agenticv1alpha1.AgenticRunConditionEscalated)
resetExecutionAndVerification(&run.Status.Steps)

while DerivePhase() checks EmergencyStopped and Denied before anything else:

if c := get(AgenticRunConditionEmergencyStopped); c != nil && c.Status == metav1.ConditionTrue {
return AgenticRunPhaseEmergencyStopped
}
escalated := get(AgenticRunConditionEscalated)
if escalated != nil && escalated.Status == metav1.ConditionTrue {
return AgenticRunPhaseEscalated
}
if c := get(AgenticRunConditionDenied); c != nil && c.Status == metav1.ConditionTrue {
return AgenticRunPhaseDenied
}

Walking through a denied run with this patch applied:

  1. User patches spec.revisionFeedback, generation becomes 2, needsRevision() returns true
  2. Falls through to phase routing, handleRevision() reruns analysis: agent invoked, AnalysisResult created, Analyzed becomes RevisionComplete (generation 2)
  3. But Denied=True was never removed, so DerivePhase() still returns Denied
  4. Next reconcile: needsRevision() is now false (generation caught up), so the run takes the terminal cleanup path and sandboxes are released

Net effect: one paid LLM analysis, one orphaned AnalysisResult, run still terminally Denied, and every later revision attempt repeats the cycle. I confirmed this with a unit test on this branch: seeded a Denied run, patched feedback, reconciled 3 times:

phase after revision: Denied
analyzed condition: {Type:Analyzed Status:True ObservedGeneration:2 Reason:RevisionComplete
                     Message:Revision complete (generation 2) with 1 option(s)}

Escalated does route correctly (the condition is cleared), but semantically revisionFeedback is documented as feedback on the analysis; an escalation, a denial, or the kill switch is not that. Scoping this PR to Completed keeps it aligned with OTA-2090 and the contract of the field; the other phases can be deliberate follow ups if a use case shows up.

Comment thread controller/agenticrun/reconciler.go Outdated
Comment on lines +214 to +218
case agenticv1alpha1.AgenticRunPhaseNoActionRequired,
agenticv1alpha1.AgenticRunPhaseCompleted,
agenticv1alpha1.AgenticRunPhaseDenied,
agenticv1alpha1.AgenticRunPhaseEscalated,
agenticv1alpha1.AgenticRunPhaseEmergencyStopped:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same restriction here. With only Completed added, this becomes:

case agenticv1alpha1.AgenticRunPhaseNoActionRequired,
	agenticv1alpha1.AgenticRunPhaseCompleted:
	if needsRevision(&run) {
		return r.handleRevision(ctx, &run, resolved)
	}
	return ctrl.Result{}, nil

Optional tightening to consider: Completed on a run that actually executed means the cluster was already remediated. Revision would reanalyse the post remediation state and could lead to a second approved execution. If we only intend the advisory flow (the OTA-2090 case), gate on the established idiom for "no execution step":

case agenticv1alpha1.AgenticRunPhaseCompleted:
	if run.Spec.Execution.IsZero() && needsRevision(&run) {
		return r.handleRevision(ctx, &run, resolved)
	}
	return ctrl.Result{}, nil

!run.Spec.Execution.IsZero() is already how the codebase detects that execution is configured:

HasExecution: !run.Spec.Execution.IsZero(),

If you gate here, mirror the same condition in the first switch so the two stay consistent. Also: the two case bodies in the first switch are now byte for byte identical, so it is worth merging NoActionRequired and Completed into a single case there too.

Comment thread controller/agenticrun/handlers_test.go Outdated
// Submit revision on the completed run
reviseAgenticRun(t, fc, "fix-crash", "re-analyse with different focus")

// Reconcile 2: Completed → revision → Proposed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: copy paste, this is the third reconcile, but the comment here and the error message on line 728 both say "Reconcile 2".

Comment thread controller/agenticrun/handlers_test.go Outdated
if agenticv1alpha1.DerivePhase(p.Status.Conditions) != agenticv1alpha1.AgenticRunPhaseProposed {
t.Fatalf("expected Proposed after revision from Completed, got %s", agenticv1alpha1.DerivePhase(p.Status.Conditions))
}
if analyzed := meta.FindStatusCondition(p.Status.Conditions, agenticv1alpha1.AgenticRunConditionAnalyzed); analyzed == nil || analyzed.ObservedGeneration == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: analyzed.ObservedGeneration == 0 would pass even if the revision never observed the new generation (the stale value is 1). Asserting analyzed.ObservedGeneration == p.Generation directly proves the fix. (I know the == 0 shape mirrors the existing revision tests, so it is fine to leave for consistency, but since observedGeneration staleness is the exact symptom in the Jira, the stronger assertion earns its keep here.)

@jhadvig
jhadvig force-pushed the fix-revision-terminal-phases branch from e33823a to 7ab3703 Compare July 23, 2026 16:19
Per harche's review, restrict the needsRevision() fall-through to
Completed only. Denied, Escalated, and EmergencyStopped are excluded
because handleRevision() doesn't clear their conditions, so
DerivePhase() would snap right back to the same terminal state.

Also tighten the test assertion to verify ObservedGeneration equals
the current Generation (not just non-zero), and update the suspension
guard comment that was made stale by the Completed fall-through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jhadvig
jhadvig force-pushed the fix-revision-terminal-phases branch from 7ab3703 to 7f38a67 Compare July 23, 2026 16:26
@harche

harche commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

LGTM, tagging @onmete @blublinsky @xrajesh in case they want to review it as well and apply the tag.

@harche

harche commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@jhadvig is going to update this PR for handling Failed state as well.

/hold remove it once the PR is updated.

@openshift-ci openshift-ci Bot added the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label Jul 23, 2026
Per Harche's approval, also allow revision from Failed advisory-only
runs. When analysis fails (e.g. LLM timeout), the user should be able
to retry via revisionFeedback. handleRevision() resets the Analyzed
condition from False/Failed to Unknown/Revising, clearing the failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@openshift-ci

openshift-ci Bot commented Jul 23, 2026

Copy link
Copy Markdown

@jhadvig: all tests passed!

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

@jhadvig

jhadvig commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

/hold cancel

@openshift-ci openshift-ci Bot removed the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label Jul 23, 2026

@harche harche left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround. Verified the latest head locally: all package tests pass, and the Failed addition is mechanically sound in a way Denied was not. There is no dedicated Failed condition for DerivePhase() to get stuck on; Failed derives from a step condition being False, and handleRevision() resets exactly those, so the run genuinely lands at Proposed. Also confirmed the no loop property: failStep() stamps ObservedGeneration with the current generation, so a second failure makes needsRevision() false and the next reconcile takes the normal handleFailed() path instead of retrying forever.

Two observations, neither blocking.

Comment on lines 157 to +160
case agenticv1alpha1.AgenticRunPhaseFailed:
return r.handleFailed(ctx, &run)
if !(run.Spec.Execution.IsZero() && needsRevision(&run)) {
return r.handleFailed(ctx, &run)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation (not blocking): revision from Failed is really "retry analysis with feedback" (the new test even models an LLM timeout) rather than the documented "feedback requesting changes to the analysis." For advisory only runs the distinction is harmless, and it is arguably a UX win: the Re-analyse button now also recovers from transient analysis failures. Just flagging it so the extension of the revisionFeedback contract is a deliberate choice rather than an accident. If it is intended, a one line addition to the field's doc comment in a follow up would keep the contract honest.

}

// --- Suspension guard (only non-terminal runs reach here) ---
// --- Suspension guard (non-terminal runs and advisory-only Completed runs needing revision reach here) ---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this comment is stale again now that Failed runs also reach the suspension guard. Suggest "non-terminal runs and advisory-only Completed/Failed runs needing revision reach here".

@onmete

onmete commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Well, terminal state means terminal. Should we revive the terminal AgenticRun instead of spawning a new one?
Re-analyzing an unfinished run sounds fine (just after analysis).

@jhadvig

jhadvig commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@onmete I think I will defer there to @harche

@harche

harche commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Fair concern from @onmete, and honestly there is no objectively right answer here; both models (revive in place vs. spawn a fresh run) are defensible. I am fine with the PR as it stands, but ultimately happy to go with whichever direction @onmete feels is right. A few data points that shaped my thinking, for whatever they are worth:

The spec seems designed for in place revision. revisionFeedback is deliberately the only mutable spec field; everything else is frozen by CEL rules precisely so that a generation bump signals "revise this run":

// revisionFeedback is the user's free-text feedback requesting changes
// to the analysis. Patching this field bumps metadata.generation, which
// the operator detects (generation > observedGeneration) and triggers
// re-analysis with the feedback appended to the original request.
//
// Mutable: this is the only mutable spec field. All other spec fields
// are immutable via CEL rules, so generation changes signal revision.
// +optional
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=32768
RevisionFeedback string `json:"revisionFeedback,omitempty"`

The precedent already exists on main. NoActionRequired is terminal per isTerminal():

func isTerminal(phase agenticv1alpha1.AgenticRunPhase) bool {
switch phase {
case agenticv1alpha1.AgenticRunPhaseCompleted, agenticv1alpha1.AgenticRunPhaseFailed, agenticv1alpha1.AgenticRunPhaseDenied, agenticv1alpha1.AgenticRunPhaseEscalated, agenticv1alpha1.AgenticRunPhaseEmergencyStopped, agenticv1alpha1.AgenticRunPhaseNoActionRequired:
return true
}
return false
}

and it has supported revision since before this PR; this change mirrors that existing pattern rather than inventing revival:

case agenticv1alpha1.AgenticRunPhaseNoActionRequired:
if !needsRevision(&run) {
if hasSandboxClaims(&run) {
if err := r.Agent.ReleaseSandboxes(ctx, &run); err != nil {
log.Error(err, "sandbox cleanup failed at terminal phase")
}
}
if r.Audit != nil {
r.Audit.EmitTerminalSpan(ctx, &run, string(phase), terminalReason(&run))
r.Audit.Cleanup(&run)
}
return ctrl.Result{}, nil
}

The CRD already models multiple rounds. Each step keeps an array of result CRs, one per round, so revision history accumulates on the same object:

run.Status.Steps.Analysis.Results = append(run.Status.Steps.Analysis.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFromBool(analysisResult.Success)})

and the revision context handed to the agent embeds the generation number, so each round is traceable:

type revisionData struct {
Generation int64
AgenticRunName string
Namespace string
Feedback string
}
func buildRevisionContext(run *agenticv1alpha1.AgenticRun) string {
data := revisionData{
Generation: run.Generation,
AgenticRunName: run.Name,
Namespace: run.Namespace,
Feedback: run.Spec.RevisionFeedback,
}
return renderTemplate("revision_context.tmpl", data)
}

Where @onmete's instinct is exactly right, and the PR agrees: for a run that actually executed, Completed genuinely is terminal; the cluster was changed, and reviving it would be misleading. That case stays terminal here. The fall through is gated on run.Spec.Execution.IsZero(), so only advisory only runs (nothing executed, Completed effectively means "report delivered") can be re analysed:

case agenticv1alpha1.AgenticRunPhaseCompleted:
if !(run.Spec.Execution.IsZero() && needsRevision(&run)) {
if hasSandboxClaims(&run) {
if err := r.Agent.ReleaseSandboxes(ctx, &run); err != nil {
log.Error(err, "sandbox cleanup failed at terminal phase")
}
}
if r.Audit != nil {
r.Audit.EmitTerminalSpan(ctx, &run, string(phase), terminalReason(&run))
r.Audit.Cleanup(&run)
}
return ctrl.Result{}, nil
}

On spawning a new run instead: totally workable too, it would just need a clone and link mechanism (carry over the request, reference the predecessor, re create the approval), and the console's Re-analyse flow would span two objects instead of one thread. That felt like a bigger feature than this bug fix, and it could still be added later without conflicting with this change if run lineage as a first class concept is ever wanted.

Also worth noting: "re analyzing just after analysis" (Proposed) already works on main. OTA-2090 is specifically about the advisory flow where the run has already reached Completed, which is why the narrow gate above is the shape of the fix.

Happy to defer to @onmete on the final call.

@jhadvig jhadvig changed the title OTA-2090: Check needsRevision() in terminal phases before early return OLS-3719, OTA-2090: Check needsRevision() in terminal phases before early return Jul 24, 2026
@openshift-ci-robot

openshift-ci-robot commented Jul 24, 2026

Copy link
Copy Markdown

@jhadvig: This pull request references OLS-3719 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the bug to target the "5.0.0" version, but no target version was set.

This pull request references OTA-2090 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the bug to target the "5.0.0" version, but no target version was set.

Details

In response to this:

Summary

Fixes a bug where patching spec.revisionFeedback on a completed AgenticRun has no effect. The "Re-analyse" button in the cluster-update-console-plugin triggers this patch, but the operator never re-enters the analysis loop.

Jira: https://redhat.atlassian.net/browse/OTA-2090

Root cause

The reconciler's first switch block handles terminal phases before the suspension guard. The NoActionRequired phase correctly guards its early return with !needsRevision(&run), falling through to the phase routing switch when a revision is needed. However, the Completed, Denied, Escalated, and EmergencyStopped phases return unconditionally without checking needsRevision():

// Before (lines 129-142):
case AgenticRunPhaseCompleted, AgenticRunPhaseDenied, ...:
   if hasSandboxClaims(&run) { ... }
   if r.Audit != nil { ... }
   return ctrl.Result{}, nil  // ← always returns, never checks revision

Fix

Two changes in controller/agenticrun/reconciler.go:

  1. First switch (terminal phases): wrap the early return in if !needsRevision(&run), mirroring the existing NoActionRequired pattern. When revision is needed, execution falls through to the phase routing switch.

  2. Second switch (phase routing): add Completed/Denied/Escalated/EmergencyStopped to the NoActionRequired case, which already calls handleRevision() when needsRevision() returns true.

How to reproduce

  1. Wait for an AgenticRun to complete via advisory-only path
  2. Patch spec.revisionFeedback:
oc patch agenticrun <name> -n openshift-lightspeed \
  --type=json -p '[{"op":"replace","path":"/spec/revisionFeedback","value":"re-analyse"}]'
  1. Observe metadata.generation increments but Analyzed.observedGeneration stays stale
  2. Operator logs show no reconciliation after the patch

Evidence

$ oc get agenticrun ota-5-0-0-ec-4-to-5-0-8 -o jsonpath='{.metadata.generation}'
2

$ oc get agenticrun ota-5-0-0-ec-4-to-5-0-8 \
   -o jsonpath='{.status.conditions[?(@.type=="Analyzed")].observedGeneration}'
1

# needsRevision() would return true (2 > 1), but reconciler never evaluates it

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

jira/valid-reference Indicates that this PR references a valid Jira ticket of any type.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants