Currently, the runner (wrapper.py) directly updates the AgenticSession CR status, which creates:
- Race conditions with operator monitoring
- Security issues (runner needs elevated CR write permissions)
- Poor handling of timeouts, pod failures, stale tokens
- Violation of "operator as source of truth" principle
┌─────────────────────────────────────────────────────────────┐
│ OPERATOR (Source of Truth) │
│ - Watches Job/Pod status │
│ - Updates CR status via Conditions │
│ - Detects timeouts, failures, ImagePullBackOff │
│ - Refreshes SA tokens when needed │
│ - Retries transient errors │
└─────────────────────────────────────────────────────────────┘
▲
│ Observes
│
┌─────────────────────────────────────────────────────────────┐
│ KUBERNETES JOB & POD │
│ - Job activeDeadlineSeconds handles timeout │
│ - Pod containerStatuses show runner state │
│ - Pod conditions show scheduling issues │
└─────────────────────────────────────────────────────────────┘
▲
│ Runs in
│
┌─────────────────────────────────────────────────────────────┐
│ RUNNER (Execution Only) │
│ - Executes Claude Code SDK │
│ - Writes progress to annotation (not status) │
│ - Sends messages via WebSocket │
│ - Exits with proper exit code │
│ - NO CR status updates │
└─────────────────────────────────────────────────────────────┘
- Write progress to annotation (not status):
async def _report_progress(self, message: str):
"""Report progress via annotation (read by operator for observability)."""
try:
timestamp = self._utc_iso()
annotation_key = "ambient-code.io/runner-progress"
annotation_value = json.dumps({
"message": message,
"timestamp": timestamp
})
await self._update_cr_annotation(annotation_key, annotation_value)
except Exception as e:
logging.debug(f"Progress annotation update failed (non-critical): {e}")- Exit with proper exit codes:
# Success
sys.exit(0)
# User-requested stop
sys.exit(143) # SIGTERM
# SDK error
sys.exit(1)
# Prerequisite validation failed
sys.exit(2)- Send real-time updates via WebSocket (UI only, not CR):
await self._send_log("Starting Claude Code session...")❌ Remove these functions from wrapper.py:
# DELETE THIS - operator handles status
async def _update_cr_status(self, fields: dict, blocking: bool = False):
# REMOVE ENTIRE FUNCTION❌ Remove all calls to _update_cr_status():
# DELETE THESE
await self._update_cr_status({"phase": "Running", ...})
await self._update_cr_status({"phase": "Completed", ...})
await self._update_cr_status({"phase": "Failed", ...})✅ Keep annotation updates (for observability):
# KEEP THIS - annotations are metadata, not status
await self._update_cr_annotation("ambient-code.io/sdk-session-id", sdk_session_id)
await self._update_cr_annotation("ambient-code.io/runner-progress", progress_json)Use Kubernetes Job's built-in timeout:
// In operator when creating Job
job := &batchv1.Job{
Spec: batchv1.JobSpec{
// Kubernetes handles timeout - no manual monitoring needed
ActiveDeadlineSeconds: int64Ptr(sessionTimeout),
BackoffLimit: int32Ptr(3),
// ...
},
}Detect timeout in reconciliation:
func (r *SessionReconciler) checkJobTimeout(ctx context.Context, session *unstructured.Unstructured, job *batchv1.Job) error {
// Job exceeded ActiveDeadlineSeconds
if job.Status.Failed > 0 {
for _, cond := range job.Status.Conditions {
if cond.Type == batchv1.JobFailed && cond.Reason == "DeadlineExceeded" {
r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
"Timeout",
fmt.Sprintf("Job exceeded timeout of %d seconds", *job.Spec.ActiveDeadlineSeconds))
r.updateStatus(ctx, session, map[string]interface{}{
"completionTime": metav1.Now(),
})
// Cleanup
r.deleteJob(ctx, session, job)
return nil
}
}
}
return nil
}Map exit codes to conditions:
func (r *SessionReconciler) handleRunnerTermination(ctx context.Context, session *unstructured.Unstructured, cs *corev1.ContainerStatus) error {
term := cs.State.Terminated
switch term.ExitCode {
case 0:
// Success
r.updateCondition(ctx, session, ConditionTypeCompleted, metav1.ConditionTrue,
"Success", "Runner completed successfully")
r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
"SessionCompleted", "Session finished successfully")
case 1:
// SDK error
r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
"SDKError", fmt.Sprintf("Runner exited with error: %s", term.Message))
r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
"SessionFailed", term.Message)
case 2:
// Prerequisite validation failed
r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
"PrerequisiteFailed", "Required prerequisite files missing")
case 143:
// SIGTERM - user requested stop (already handled by StopSession)
log.Printf("Runner received SIGTERM (user stop)")
default:
// Unknown error
r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
"UnknownError", fmt.Sprintf("Runner exited with code %d: %s", term.ExitCode, term.Message))
}
// Always set completion time
r.updateStatus(ctx, session, map[string]interface{}{
"completionTime": metav1.Now(),
})
// Set interactive for restart
r.setSpecField(ctx, session, "interactive", true)
// Cleanup Job
r.deleteJob(ctx, session, job)
return nil
}Monitor token age and recreate before expiration:
func (r *SessionReconciler) ensureFreshToken(ctx context.Context, session *unstructured.Unstructured) error {
name := session.GetName()
namespace := session.GetNamespace()
// Get token secret
secretName := fmt.Sprintf("ambient-runner-token-%s", name)
secret, err := r.K8sClient.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
if errors.IsNotFound(err) {
// Secret missing - recreate it
log.Printf("Token secret missing for session %s, recreating", name)
return r.provisionRunnerToken(ctx, session)
}
if err != nil {
return fmt.Errorf("failed to check token secret: %w", err)
}
// Check token age (ServiceAccount tokens expire after 1 hour by default)
creationTime := secret.CreationTimestamp.Time
age := time.Since(creationTime)
// Refresh token if older than 45 minutes (15 min buffer)
if age > 45*time.Minute {
log.Printf("Token for session %s is %v old, refreshing", name, age)
// Delete old secret
err := r.K8sClient.CoreV1().Secrets(namespace).Delete(ctx, secretName, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete old token: %w", err)
}
// Create fresh token
return r.provisionRunnerToken(ctx, session)
}
return nil
}
func (r *SessionReconciler) provisionRunnerToken(ctx context.Context, session *unstructured.Unstructured) error {
name := session.GetName()
namespace := session.GetNamespace()
saName := fmt.Sprintf("ambient-session-%s", name)
// Mint fresh token
tr := &authnv1.TokenRequest{
Spec: authnv1.TokenRequestSpec{
// Request token with 1 hour expiration
ExpirationSeconds: int64Ptr(3600),
},
}
tok, err := r.K8sClient.CoreV1().ServiceAccounts(namespace).CreateToken(ctx, saName, tr, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to mint token: %w", err)
}
// Store in secret
secretName := fmt.Sprintf("ambient-runner-token-%s", name)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: namespace,
Labels: map[string]string{"app": "ambient-runner-token"},
OwnerReferences: []metav1.OwnerReference{{
APIVersion: session.GetAPIVersion(),
Kind: session.GetKind(),
Name: session.GetName(),
UID: session.GetUID(),
Controller: boolPtr(true),
}},
},
Type: corev1.SecretTypeOpaque,
StringData: map[string]string{
"k8s-token": tok.Status.Token,
},
}
_, err = r.K8sClient.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
if errors.IsAlreadyExists(err) {
// Update existing secret
_, err = r.K8sClient.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{})
}
log.Printf("Provisioned fresh token for session %s (expires in 1h)", name)
return err
}func (r *SessionReconciler) reconcileSession(ctx context.Context, session *unstructured.Unstructured) (ctrl.Result, error) {
name := session.GetName()
namespace := session.GetNamespace()
// Step 1: Ensure token is fresh (refresh if > 45min old)
if err := r.ensureFreshToken(ctx, session); err != nil {
r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
"TokenRefreshFailed", fmt.Sprintf("Failed to refresh SA token: %v", err))
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil // Retry
}
// Step 2: Ensure PVC exists and is bound
pvcReady, err := r.ensurePVC(ctx, session)
if err != nil {
r.updateCondition(ctx, session, ConditionTypePVCReady, metav1.ConditionFalse,
"ProvisionFailed", err.Error())
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
if !pvcReady {
r.updateCondition(ctx, session, ConditionTypePVCReady, metav1.ConditionFalse,
"Provisioning", "PVC is being provisioned")
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
r.updateCondition(ctx, session, ConditionTypePVCReady, metav1.ConditionTrue,
"Bound", "PVC is bound and ready")
// Step 3: Verify secrets exist
secretsReady, missingSecret, err := r.verifySecrets(ctx, session)
if err != nil {
r.updateCondition(ctx, session, ConditionTypeSecretsReady, metav1.ConditionFalse,
"VerificationFailed", err.Error())
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
if !secretsReady {
r.updateCondition(ctx, session, ConditionTypeSecretsReady, metav1.ConditionFalse,
"SecretNotFound", fmt.Sprintf("Secret '%s' not found", missingSecret))
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
r.updateCondition(ctx, session, ConditionTypeSecretsReady, metav1.ConditionTrue,
"AllSecretsFound", "All required secrets are present")
// Step 4: Ensure Job exists
jobName := fmt.Sprintf("%s-job", name)
job, err := r.K8sClient.BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{})
if errors.IsNotFound(err) {
// Create Job
job, err = r.createJob(ctx, session)
if err != nil {
r.updateCondition(ctx, session, ConditionTypeJobCreated, metav1.ConditionFalse,
"CreationFailed", err.Error())
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
r.updateCondition(ctx, session, ConditionTypeJobCreated, metav1.ConditionTrue,
"Created", "Job created successfully")
return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // Let pod schedule
}
if err != nil {
return ctrl.Result{RequeueAfter: 5 * time.Second}, fmt.Errorf("failed to get job: %w", err)
}
// Step 5: Check for Job timeout
if err := r.checkJobTimeout(ctx, session, job); err != nil {
return ctrl.Result{}, err
}
// Step 6: Check Job failure (backoff limit exceeded)
if job.Spec.BackoffLimit != nil && job.Status.Failed >= *job.Spec.BackoffLimit {
r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
"BackoffLimitExceeded",
fmt.Sprintf("Job failed after %d attempts", job.Status.Failed))
r.updateStatus(ctx, session, map[string]interface{}{
"completionTime": metav1.Now(),
})
r.deleteJob(ctx, session, job)
return ctrl.Result{}, nil // Terminal
}
// Step 7: Monitor pod status
pods, err := r.K8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("job-name=%s", jobName),
})
if err != nil {
return ctrl.Result{RequeueAfter: 5 * time.Second}, err
}
if len(pods.Items) == 0 {
// No pods yet - waiting for scheduler
r.updateCondition(ctx, session, ConditionTypePodScheduled, metav1.ConditionFalse,
"PodPending", "Waiting for pod to be scheduled")
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
pod := pods.Items[0]
// Check pod phase-level failures
if pod.Status.Phase == corev1.PodFailed {
failureMsg := fmt.Sprintf("Pod failed: %s - %s", pod.Status.Reason, pod.Status.Message)
r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
"PodFailed", failureMsg)
r.updateStatus(ctx, session, map[string]interface{}{
"completionTime": metav1.Now(),
})
r.deleteJob(ctx, session, job)
return ctrl.Result{}, nil // Terminal
}
// Check pod scheduling
if pod.Spec.NodeName != "" {
r.updateCondition(ctx, session, ConditionTypePodScheduled, metav1.ConditionTrue,
"Scheduled", fmt.Sprintf("Pod scheduled on node %s", pod.Spec.NodeName))
} else {
// Check for scheduling issues
for _, cond := range pod.Status.Conditions {
if cond.Type == corev1.PodScheduled && cond.Status == corev1.ConditionFalse {
r.updateCondition(ctx, session, ConditionTypePodScheduled, metav1.ConditionFalse,
cond.Reason, cond.Message)
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
}
}
// Step 8: Check runner container status
runnerCS := getContainerStatus(&pod, "ambient-code-runner")
if runnerCS == nil {
return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // Container not ready
}
// Container running
if runnerCS.State.Running != nil {
r.updateCondition(ctx, session, ConditionTypeRunnerStarted, metav1.ConditionTrue,
"ContainerRunning", "Runner container is active")
r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionTrue,
"SessionRunning", "Session is running normally")
// Set start time if not set
if getStartTime(session) == nil {
r.updateStatus(ctx, session, map[string]interface{}{
"startTime": metav1.Now(),
})
}
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil // Keep monitoring
}
// Container waiting (check for errors)
if runnerCS.State.Waiting != nil {
waiting := runnerCS.State.Waiting
isPermanentError := false
switch waiting.Reason {
case "ImagePullBackOff", "ErrImagePull", "InvalidImageName":
isPermanentError = true
case "CrashLoopBackOff":
isPermanentError = runnerCS.RestartCount > 3 // Permanent after 3 retries
case "CreateContainerConfigError":
isPermanentError = true
}
if isPermanentError {
r.updateCondition(ctx, session, ConditionTypeRunnerStarted, metav1.ConditionFalse,
waiting.Reason, waiting.Message)
r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
waiting.Reason, fmt.Sprintf("Runner container failed: %s", waiting.Message))
r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
"SessionFailed", waiting.Message)
r.updateStatus(ctx, session, map[string]interface{}{
"completionTime": metav1.Now(),
})
r.deleteJob(ctx, session, job)
return ctrl.Result{}, nil // Terminal
} else {
// Transient error - keep retrying
r.updateCondition(ctx, session, ConditionTypeRunnerStarted, metav1.ConditionFalse,
waiting.Reason, waiting.Message)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
}
// Container terminated
if runnerCS.State.Terminated != nil {
return r.handleRunnerTermination(ctx, session, runnerCS)
}
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}- Update operator to handle all failure scenarios
- Add token refresh logic
- Add condition-based reconciliation
- Keep runner status updates for now (backward compatible)
- Add exit code signaling
- Add progress annotations
- Keep status updates temporarily (log deprecation warnings)
- Remove
_update_cr_status()from wrapper.py - Update runner RBAC to remove CR write permissions
- Operator is now sole source of truth
- Test all failure scenarios (timeout, ImagePullBackOff, etc.)
- Verify token refresh works
- Monitor for race conditions
- Verify UI shows correct status from conditions
✅ No stuck sessions - Operator detects and handles all failure modes ✅ Better security - Runner doesn't need CR write access ✅ Token refresh - Operator handles expiration automatically ✅ Timeout handling - Kubernetes Job handles it natively ✅ Clearer debugging - Conditions show exactly what failed ✅ Separation of concerns - Runner executes, operator manages lifecycle
- Happy path: Session completes successfully → exit code 0
- Timeout: Job exceeds ActiveDeadlineSeconds → Condition: Timeout
- Image pull error: Bad image → Condition: ImagePullBackOff
- Secret missing: Runner secrets not found → Condition: SecretsNotReady
- Stale token: Token > 45min old → Auto-refreshed before job creation
- Pod eviction: Node pressure → Condition: PodEvicted, Job retries
- Runner crash: SDK error → exit code 1 → Condition: SDKError
- User stop: DELETE job → exit code 143 → Condition: Stopped