Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions api/v1alpha1/agenticolsconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ const (
AgenticOLSConfigConditionSuspended = "Suspended"
)

// LifecycleConfig controls automatic cleanup of terminal AgenticRun resources.
//
// +kubebuilder:validation:MinProperties=1
type LifecycleConfig struct {
// terminalTTL is the default time-to-live in seconds for terminal
// AgenticRun resources (Completed, Failed, Denied, Escalated,
// EmergencyStopped, NoActionRequired). After a run reaches a terminal
// state and this many seconds elapse, the operator deletes the
// AgenticRun CR. Kubernetes garbage collection cascades deletion to
// owned resources via owner references.
//
// Per-run overrides via AgenticRun.spec.ttlAfterTerminal take
// precedence over this cluster-wide default.
//
// When omitted (nil), no automatic deletion occurs.
// +optional
// +kubebuilder:validation:Minimum=0
TerminalTTL *int32 `json:"terminalTTL,omitempty"`
}

// AgenticOLSConfigSpec defines the desired state of AgenticOLSConfig.
//
// +kubebuilder:validation:MinProperties=1
Expand All @@ -41,6 +61,11 @@ type AgenticOLSConfigSpec struct {
// +optional
// +default=false
Suspended bool `json:"suspended,omitempty"` //nolint:kubeapilinter // kill switch is genuinely binary; bool is the right type

// lifecycle controls automatic cleanup of terminal AgenticRun resources.
// When omitted, no automatic deletion occurs (backwards-compatible).
// +optional
Lifecycle LifecycleConfig `json:"lifecycle,omitzero"`
}

// AgenticOLSConfigStatus defines the observed state of AgenticOLSConfig.
Expand Down
25 changes: 25 additions & 0 deletions api/v1alpha1/agenticrun_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,23 @@ type AgenticRunSpec struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=32768
RevisionFeedback string `json:"revisionFeedback,omitempty"`

// ttlAfterTerminal is the time-to-live in seconds for this AgenticRun
// after it reaches a terminal state (Completed, Failed, Denied,
// Escalated, EmergencyStopped, NoActionRequired). When the TTL expires,
// the operator deletes the AgenticRun CR and Kubernetes garbage
// collection cascades deletion to owned resources.
//
// Overrides the cluster-wide default from
// AgenticOLSConfig.spec.lifecycle.terminalTTL for this run.
//
// Set to 0 to disable automatic deletion for this run.
//
// Mutable: adapters or admins may pre-set this before the run reaches
// terminal state. The operator will not overwrite a pre-set value.
// +optional
// +kubebuilder:validation:Minimum=0
TTLAfterTerminal *int32 `json:"ttlAfterTerminal,omitempty"`
}

// AgenticRunStatus defines the observed state of AgenticRun. All fields are
Expand Down Expand Up @@ -406,6 +423,14 @@ type AgenticRunStatus struct {
// info, and references to result CRs.
// +optional
Steps StepsStatus `json:"steps,omitzero"`

// terminalTime is the timestamp when the run first reached a terminal
// state (Completed, Failed, Denied, Escalated, EmergencyStopped,
// NoActionRequired). Set once by the operator and never updated.
// Used together with spec.ttlAfterTerminal to compute when the run
// should be garbage-collected.
// +optional
TerminalTime *metav1.Time `json:"terminalTime,omitempty"`
}

// +kubebuilder:object:root=true
Expand Down
23 changes: 23 additions & 0 deletions config/crd/bases/agentic.openshift.io_agenticolsconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ spec:
description: spec defines the desired system configuration.
minProperties: 1
properties:
lifecycle:
description: |-
lifecycle controls automatic cleanup of terminal AgenticRun resources.
When omitted, no automatic deletion occurs (backwards-compatible).
minProperties: 1
properties:
terminalTTL:
description: |-
terminalTTL is the default time-to-live in seconds for terminal
AgenticRun resources (Completed, Failed, Denied, Escalated,
EmergencyStopped, NoActionRequired). After a run reaches a terminal
state and this many seconds elapse, the operator deletes the
AgenticRun CR. Kubernetes garbage collection cascades deletion to
owned resources via owner references.

Per-run overrides via AgenticRun.spec.ttlAfterTerminal take
precedence over this cluster-wide default.

When omitted (nil), no automatic deletion occurs.
format: int32
minimum: 0
type: integer
type: object
suspended:
default: false
description: |-
Expand Down
27 changes: 27 additions & 0 deletions config/crd/bases/agentic.openshift.io_agenticruns.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,24 @@ spec:
- image
x-kubernetes-list-type: map
type: object
ttlAfterTerminal:
description: |-
ttlAfterTerminal is the time-to-live in seconds for this AgenticRun
after it reaches a terminal state (Completed, Failed, Denied,
Escalated, EmergencyStopped, NoActionRequired). When the TTL expires,
the operator deletes the AgenticRun CR and Kubernetes garbage
collection cascades deletion to owned resources.

Overrides the cluster-wide default from
AgenticOLSConfig.spec.lifecycle.terminalTTL for this run.

Set to 0 to disable automatic deletion for this run.

Mutable: adapters or admins may pre-set this before the run reaches
terminal state. The operator will not overwrite a pre-set value.
format: int32
minimum: 0
type: integer
verification:
description: |-
verification defines per-step configuration for the verification step.
Expand Down Expand Up @@ -2268,6 +2286,15 @@ spec:
type: object
type: object
type: object
terminalTime:
description: |-
terminalTime is the timestamp when the run first reached a terminal
state (Completed, Failed, Denied, Escalated, EmergencyStopped,
NoActionRequired). Set once by the operator and never updated.
Used together with spec.ttlAfterTerminal to compute when the run
should be garbage-collected.
format: date-time
type: string
type: object
required:
- spec
Expand Down
13 changes: 13 additions & 0 deletions controller/agenticrun/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ func isSuspended(ctx context.Context, c client.Client) (bool, error) {
return config.Spec.Suspended, nil
}

// getTerminalTTL returns the cluster-wide default TTL from AgenticOLSConfig,
// or nil if no config exists or no TTL is configured.
func getTerminalTTL(ctx context.Context, c client.Client) (*int32, error) {
var config agenticv1alpha1.AgenticOLSConfig
if err := c.Get(ctx, client.ObjectKey{Name: "cluster"}, &config); err != nil {
if client.IgnoreNotFound(err) == nil {
return nil, nil
}
return nil, err
}
return config.Spec.Lifecycle.TerminalTTL, nil
}

// failStep marks a step as failed and creates a failure result CR.
// The caller must have set the step condition to ConditionUnknown before
// calling failStep so that conditionTime can extract the start time.
Expand Down
95 changes: 93 additions & 2 deletions controller/agenticrun/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"time"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
Expand All @@ -24,6 +25,8 @@ const (
ErrAddFinalizer = "add finalizer"
ErrPatchTemplogCleanupAttempts = "patch templog cleanup attempts"
ErrPatchRBACCleanupAttempts = "patch rbac cleanup attempts"
ErrStampTerminalTTL = "stamp terminal TTL"
ErrDeleteExpiredRun = "delete expired run"
)

// TempLogCleaner is the interface for deleting templog records on CR deletion.
Expand Down Expand Up @@ -115,6 +118,9 @@ func (r *AgenticRunReconciler) Reconcile(ctx context.Context, req ctrl.Request)
r.Audit.EmitTerminalSpan(ctx, &run, string(phase), terminalReason(&run))
r.Audit.Cleanup(&run)
}
if result, requeue, err := r.handleTerminalTTL(ctx, &run); requeue || err != nil {
return result, err
}
return ctrl.Result{}, nil
}

Expand All @@ -129,6 +135,9 @@ func (r *AgenticRunReconciler) Reconcile(ctx context.Context, req ctrl.Request)
r.Audit.EmitTerminalSpan(ctx, &run, string(phase), terminalReason(&run))
r.Audit.Cleanup(&run)
}
if result, requeue, err := r.handleTerminalTTL(ctx, &run); requeue || err != nil {
return result, err
}
return ctrl.Result{}, nil
}

Expand All @@ -144,11 +153,20 @@ func (r *AgenticRunReconciler) Reconcile(ctx context.Context, req ctrl.Request)
r.Audit.EmitTerminalSpan(ctx, &run, string(phase), terminalReason(&run))
r.Audit.Cleanup(&run)
}
if result, requeue, err := r.handleTerminalTTL(ctx, &run); requeue || err != nil {
return result, err
}
return ctrl.Result{}, nil

case agenticv1alpha1.AgenticRunPhaseFailed:
if !(run.Spec.Execution.IsZero() && needsRevision(&run)) {
return r.handleFailed(ctx, &run)
if result, err := r.handleFailed(ctx, &run); err != nil {
return result, err
}
if result, requeue, err := r.handleTerminalTTL(ctx, &run); requeue || err != nil {
return result, err
}
return ctrl.Result{}, nil
}
}

Expand Down Expand Up @@ -257,7 +275,10 @@ func (r *AgenticRunReconciler) SetupWithManager(mgr ctrl.Manager) error {
}
var reqs []ctrl.Request
for _, p := range runs.Items {
if !isTerminal(agenticv1alpha1.DerivePhase(p.Status.Conditions)) {
phase := agenticv1alpha1.DerivePhase(p.Status.Conditions)
// Enqueue non-terminal runs (normal workflow) and terminal runs
// that may need TTL stamping (terminalTime set but no ttlAfterTerminal yet).
if !isTerminal(phase) || (p.Status.TerminalTime != nil && p.Spec.TTLAfterTerminal == nil) {
Comment on lines +278 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Enqueue terminal runs with incomplete TTL metadata.

Line 281 skips a terminal run when TerminalTime is nil, even if TTLAfterTerminal is also nil. Terminal runs created before this change have both fields unset. A later AgenticOLSConfig update will not reconcile them, so the default TTL is never applied.

Enqueue terminal runs when either TTL metadata field is missing.

Proposed fix
- if !isTerminal(phase) || (p.Status.TerminalTime != nil && p.Spec.TTLAfterTerminal == nil) {
+ if !isTerminal(phase) || p.Status.TerminalTime == nil || p.Spec.TTLAfterTerminal == nil {
    reqs = append(reqs, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&p)})
  }

Based on PR objectives, configuration changes must enqueue terminal runs that require TTL metadata.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
phase := agenticv1alpha1.DerivePhase(p.Status.Conditions)
// Enqueue non-terminal runs (normal workflow) and terminal runs
// that may need TTL stamping (terminalTime set but no ttlAfterTerminal yet).
if !isTerminal(phase) || (p.Status.TerminalTime != nil && p.Spec.TTLAfterTerminal == nil) {
phase := agenticv1alpha1.DerivePhase(p.Status.Conditions)
// Enqueue non-terminal runs (normal workflow) and terminal runs
// that may need TTL stamping (terminalTime set but no ttlAfterTerminal yet).
if !isTerminal(phase) || p.Status.TerminalTime == nil || p.Spec.TTLAfterTerminal == nil {
🤖 Prompt for 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.

In `@controller/agenticrun/reconciler.go` around lines 278 - 281, Update the
enqueue condition in the reconciler around DerivePhase and isTerminal so
terminal runs are enqueued whenever either TerminalTime or TTLAfterTerminal is
missing. Preserve enqueueing for all non-terminal runs and the existing handling
for terminal runs with incomplete TTL metadata.

reqs = append(reqs, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&p)})
}
}
Expand Down Expand Up @@ -286,6 +307,76 @@ func (r *AgenticRunReconciler) SetupWithManager(mgr ctrl.Manager) error {
Complete(r)
}

// handleTerminalTTL stamps terminalTime and ttlAfterTerminal on a terminal run,
// then checks whether the TTL has expired. If expired, it deletes the AgenticRun
// CR (Kubernetes GC cascades to owned resources). If not expired, it returns a
// RequeueAfter for the remaining TTL. Returns (result, requeue, error) where
// requeue=true means the caller should return the result instead of continuing.
func (r *AgenticRunReconciler) handleTerminalTTL(ctx context.Context, run *agenticv1alpha1.AgenticRun) (ctrl.Result, bool, error) {
log := logf.FromContext(ctx)
now := metav1.Now()

// --- Stamp terminalTime if not yet set ---
if run.Status.TerminalTime == nil {
base := run.DeepCopy()
run.Status.TerminalTime = &now
if err := r.statusPatch(ctx, run, base); err != nil {
log.Error(err, "failed to stamp terminalTime")
return ctrl.Result{}, false, err
}
}

// --- Stamp ttlAfterTerminal from cluster config if not already set ---
if run.Spec.TTLAfterTerminal == nil {
clusterTTL, err := getTerminalTTL(ctx, r.Client)
if err != nil {
return ctrl.Result{}, false, fmt.Errorf("%s: %w", ErrStampTerminalTTL, err)
}
if clusterTTL != nil {
// Re-fetch to avoid conflicts after the status patch above.
if err := r.Get(ctx, client.ObjectKeyFromObject(run), run); err != nil {
return ctrl.Result{}, false, client.IgnoreNotFound(err)
}
original := run.DeepCopy()
run.Spec.TTLAfterTerminal = clusterTTL
if err := r.Patch(ctx, run, client.MergeFrom(original)); err != nil {
log.Error(err, "failed to stamp ttlAfterTerminal")
return ctrl.Result{}, false, err
}
}
}

// --- Evaluate TTL ---
if run.Spec.TTLAfterTerminal == nil {
// No TTL configured — no auto-deletion.
return ctrl.Result{}, false, nil
}

ttlSeconds := *run.Spec.TTLAfterTerminal
if ttlSeconds == 0 {
// TTL=0 explicitly disables auto-deletion for this run.
return ctrl.Result{}, false, nil
}

terminalTime := run.Status.TerminalTime.Time
expiry := terminalTime.Add(time.Duration(ttlSeconds) * time.Second)
remaining := time.Until(expiry)

if remaining <= 0 {
log.Info("TTL expired, deleting AgenticRun", LogKeyName, run.Name)
if err := r.Delete(ctx, run); err != nil {
if client.IgnoreNotFound(err) == nil {
return ctrl.Result{}, true, nil
}
return ctrl.Result{}, false, fmt.Errorf("%s: %w", ErrDeleteExpiredRun, err)
}
return ctrl.Result{}, true, nil
}

log.V(1).Info("TTL not yet expired, requeueing", LogKeyName, run.Name, "remaining", remaining)
return ctrl.Result{RequeueAfter: remaining}, true, nil
}

// handleTemplogCleanup deletes audit logs from the Collector's Postgres store
// for this AgenticRun. Retries up to templogMaxCleanupAttempts, then removes
// the finalizer regardless to unblock CR deletion.
Expand Down
Loading