From 339a3bd058a56e90c405cc620fb16ea6adc4458c Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 9 Jun 2026 12:17:35 +0200 Subject: [PATCH] feat: add Parent print column to Task CRD Adds a priority=1 kubectl print column showing spec.parentRef.name, so `kubectl get tasks -o wide` reveals parent-child relationships. Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/task_types.go | 30 ++ api/v1alpha1/zz_generated.deepcopy.go | 40 +++ internal/controller/job_builder.go | 10 + internal/controller/job_builder_test.go | 41 +++ internal/controller/task_controller.go | 87 +++++ internal/controller/task_controller_test.go | 315 ++++++++++++++++++ .../charts/kelos/templates/crds/task-crd.yaml | 39 +++ internal/manifests/install-crd.yaml | 39 +++ test/integration/task_test.go | 80 +++-- 9 files changed, 648 insertions(+), 33 deletions(-) diff --git a/api/v1alpha1/task_types.go b/api/v1alpha1/task_types.go index 0e4dade46..acad49721 100644 --- a/api/v1alpha1/task_types.go +++ b/api/v1alpha1/task_types.go @@ -286,6 +286,27 @@ type TaskSpec struct { // PodOverrides allows customizing the agent pod configuration. // +optional PodOverrides *PodOverrides `json:"podOverrides,omitempty"` + + // ParentRef identifies the parent Task that spawned this child Task. + // When set, the parent Task's status.childTasks is updated with this + // Task's name and phase. ParentRef is immutable after creation. + // +optional + ParentRef *TaskReference `json:"parentRef,omitempty"` +} + +// TaskReference refers to a Task resource by name within the same namespace. +type TaskReference struct { + // Name is the name of the referenced Task. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// ChildTaskStatus records the name and current phase of a child Task. +type ChildTaskStatus struct { + // Name is the child Task's name. + Name string `json:"name"` + // Phase is the child Task's current phase. + Phase TaskPhase `json:"phase,omitempty"` } // TaskStatus defines the observed state of Task. @@ -322,6 +343,14 @@ type TaskStatus struct { // Results contains structured key-value outputs produced by the agent. // +optional Results map[string]string `json:"results,omitempty"` + + // ChildTasks lists child Tasks spawned by this Task's agent and their + // current phases. Updated by the controller when child Tasks with a + // matching parentRef are created or change phase. + // +optional + // +listType=map + // +listMapKey=name + ChildTasks []ChildTaskStatus `json:"childTasks,omitempty"` } // +genclient @@ -331,6 +360,7 @@ type TaskStatus struct { // +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` // +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.spec.branch`,priority=1 +// +kubebuilder:printcolumn:name="Parent",type=string,JSONPath=`.spec.parentRef.name`,priority=1 // +kubebuilder:printcolumn:name="Depends On",type=string,JSONPath=`.spec.dependsOn`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b6e0ed2db..7b92f79eb 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -148,6 +148,21 @@ func (in *AgentDefinition) DeepCopy() *AgentDefinition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ChildTaskStatus) DeepCopyInto(out *ChildTaskStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChildTaskStatus. +func (in *ChildTaskStatus) DeepCopy() *ChildTaskStatus { + if in == nil { + return nil + } + out := new(ChildTaskStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ContextSource) DeepCopyInto(out *ContextSource) { *out = *in @@ -1041,6 +1056,21 @@ func (in *TaskList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskReference) DeepCopyInto(out *TaskReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskReference. +func (in *TaskReference) DeepCopy() *TaskReference { + if in == nil { + return nil + } + out := new(TaskReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TaskSpawner) DeepCopyInto(out *TaskSpawner) { *out = *in @@ -1192,6 +1222,11 @@ func (in *TaskSpec) DeepCopyInto(out *TaskSpec) { *out = new(PodOverrides) (*in).DeepCopyInto(*out) } + if in.ParentRef != nil { + in, out := &in.ParentRef, &out.ParentRef + *out = new(TaskReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskSpec. @@ -1227,6 +1262,11 @@ func (in *TaskStatus) DeepCopyInto(out *TaskStatus) { (*out)[key] = val } } + if in.ChildTasks != nil { + in, out := &in.ChildTasks, &out.ChildTasks + *out = make([]ChildTaskStatus, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskStatus. diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index 0bc599d4e..d921de3f2 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -302,6 +302,16 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS Value: task.Spec.Type, }) + envVars = append(envVars, corev1.EnvVar{ + Name: "KELOS_TASK_NAME", + Value: task.Name, + }) + + envVars = append(envVars, corev1.EnvVar{ + Name: "KELOS_TASK_NAMESPACE", + Value: task.Namespace, + }) + if spawner := task.Labels["kelos.dev/taskspawner"]; spawner != "" { envVars = append(envVars, corev1.EnvVar{ Name: "KELOS_TASKSPAWNER", diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index 6d512b703..517f117a9 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -5865,3 +5865,44 @@ func TestBuildJob_CrossListNameCollisionRejected(t *testing.T) { t.Errorf("Build() error = %v, want error mentioning both lists", err) } } + +func TestBuildJob_TaskNameAndNamespaceEnvVars(t *testing.T) { + builder := NewJobBuilder() + task := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-task", + Namespace: "my-namespace", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: AgentTypeClaudeCode, + Prompt: "Hello", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, + }, + }, + } + + job, err := builder.Build(task, nil, nil, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + envMap := make(map[string]string) + for _, env := range container.Env { + envMap[env.Name] = env.Value + } + + if val, ok := envMap["KELOS_TASK_NAME"]; !ok { + t.Error("Expected KELOS_TASK_NAME env var to be set") + } else if val != "my-task" { + t.Errorf("KELOS_TASK_NAME: expected %q, got %q", "my-task", val) + } + + if val, ok := envMap["KELOS_TASK_NAMESPACE"]; !ok { + t.Error("Expected KELOS_TASK_NAMESPACE env var to be set") + } else if val != "my-namespace" { + t.Errorf("KELOS_TASK_NAMESPACE: expected %q, got %q", "my-namespace", val) + } +} diff --git a/internal/controller/task_controller.go b/internal/controller/task_controller.go index 137442244..81465f963 100644 --- a/internal/controller/task_controller.go +++ b/internal/controller/task_controller.go @@ -191,6 +191,14 @@ func (r *TaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. } } + // Update parent task's childTasks status if this is a child task + if task.Spec.ParentRef != nil { + if err := r.updateParentChildStatus(ctx, &task); err != nil { + logger.Error(err, "Unable to update parent child status", "parent", task.Spec.ParentRef.Name) + return ctrl.Result{}, err + } + } + // Check TTL expiration for finished Tasks if expired, requeueAfter := r.ttlExpired(&task); expired { logger.Info("Deleting Task due to TTL expiration", "task", task.Name) @@ -223,6 +231,14 @@ func (r *TaskReconciler) handleDeletion(ctx context.Context, task *kelos.Task) ( r.BranchLocker.Release(branchLockKey(task), task.Name) } + // Remove this child from the parent's status.childTasks + if task.Spec.ParentRef != nil { + if err := r.removeParentChildStatus(ctx, task); err != nil { + logger.Error(err, "Unable to remove child from parent status", "parent", task.Spec.ParentRef.Name) + return ctrl.Result{}, err + } + } + // Delete the Job if it exists var job batchv1.Job if err := r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: task.Name}, &job); err == nil { @@ -1310,3 +1326,74 @@ func (r *TaskReconciler) enqueueDependentTasks(ctx context.Context, obj client.O } return requests } + +// updateParentChildStatus updates the parent Task's status.childTasks with +// this child task's current phase. +func (r *TaskReconciler) updateParentChildStatus(ctx context.Context, task *kelosv1alpha1.Task) error { + logger := log.FromContext(ctx) + parentName := task.Spec.ParentRef.Name + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + var parent kelosv1alpha1.Task + if err := r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: parentName}, &parent); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("Parent task not found, skipping child status update", "parent", parentName) + return nil + } + return err + } + + // Find or create entry for this child task + found := false + for i, child := range parent.Status.ChildTasks { + if child.Name == task.Name { + if child.Phase == task.Status.Phase { + // No change needed + return nil + } + parent.Status.ChildTasks[i].Phase = task.Status.Phase + found = true + break + } + } + if !found { + parent.Status.ChildTasks = append(parent.Status.ChildTasks, kelosv1alpha1.ChildTaskStatus{ + Name: task.Name, + Phase: task.Status.Phase, + }) + } + + return r.Status().Update(ctx, &parent) + }) +} + +// removeParentChildStatus removes a deleted child task from the parent's +// status.childTasks list. +func (r *TaskReconciler) removeParentChildStatus(ctx context.Context, task *kelosv1alpha1.Task) error { + logger := log.FromContext(ctx) + parentName := task.Spec.ParentRef.Name + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + var parent kelosv1alpha1.Task + if err := r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: parentName}, &parent); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("Parent task not found, skipping child status removal", "parent", parentName) + return nil + } + return err + } + + filtered := make([]kelosv1alpha1.ChildTaskStatus, 0, len(parent.Status.ChildTasks)) + for _, child := range parent.Status.ChildTasks { + if child.Name != task.Name { + filtered = append(filtered, child) + } + } + if len(filtered) == len(parent.Status.ChildTasks) { + return nil + } + parent.Status.ChildTasks = filtered + + return r.Status().Update(ctx, &parent) + }) +} diff --git a/internal/controller/task_controller_test.go b/internal/controller/task_controller_test.go index d19ce8847..ec8770364 100644 --- a/internal/controller/task_controller_test.go +++ b/internal/controller/task_controller_test.go @@ -1411,3 +1411,318 @@ func TestUpdateStatusClearsStalePodNameWhenNoLivePodsRemain(t *testing.T) { t.Fatalf("task.Status.PodName = %q, want empty", updated.Status.PodName) } } + +func TestUpdateParentChildStatus(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) + + parent := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-task", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Do something", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + }, + } + + child := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-task-child-api", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Sub task", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + ParentRef: &kelosv1alpha1.TaskReference{Name: "parent-task"}, + }, + Status: kelosv1alpha1.TaskStatus{ + Phase: kelosv1alpha1.TaskPhaseRunning, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(parent, child). + WithStatusSubresource(parent, child). + Build() + + r := &TaskReconciler{ + Client: cl, + Scheme: scheme, + } + + ctx := context.Background() + + // Test: child task updates parent's childTasks status + if err := r.updateParentChildStatus(ctx, child); err != nil { + t.Fatalf("updateParentChildStatus() error: %v", err) + } + + updatedParent := &kelosv1alpha1.Task{} + if err := cl.Get(ctx, client.ObjectKeyFromObject(parent), updatedParent); err != nil { + t.Fatalf("getting updated parent: %v", err) + } + + if len(updatedParent.Status.ChildTasks) != 1 { + t.Fatalf("expected 1 child task entry, got %d", len(updatedParent.Status.ChildTasks)) + } + if updatedParent.Status.ChildTasks[0].Name != "parent-task-child-api" { + t.Errorf("child task name: expected %q, got %q", "parent-task-child-api", updatedParent.Status.ChildTasks[0].Name) + } + if updatedParent.Status.ChildTasks[0].Phase != kelosv1alpha1.TaskPhaseRunning { + t.Errorf("child task phase: expected %q, got %q", kelosv1alpha1.TaskPhaseRunning, updatedParent.Status.ChildTasks[0].Phase) + } + + // Test: phase update propagates + child.Status.Phase = kelosv1alpha1.TaskPhaseSucceeded + if err := cl.Status().Update(ctx, child); err != nil { + t.Fatalf("updating child status: %v", err) + } + + if err := r.updateParentChildStatus(ctx, child); err != nil { + t.Fatalf("updateParentChildStatus() on phase change error: %v", err) + } + + if err := cl.Get(ctx, client.ObjectKeyFromObject(parent), updatedParent); err != nil { + t.Fatalf("getting updated parent after phase change: %v", err) + } + + if len(updatedParent.Status.ChildTasks) != 1 { + t.Fatalf("expected 1 child task entry after update, got %d", len(updatedParent.Status.ChildTasks)) + } + if updatedParent.Status.ChildTasks[0].Phase != kelosv1alpha1.TaskPhaseSucceeded { + t.Errorf("child task phase after update: expected %q, got %q", kelosv1alpha1.TaskPhaseSucceeded, updatedParent.Status.ChildTasks[0].Phase) + } +} + +func TestUpdateParentChildStatus_ParentNotFound(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) + + child := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "orphan-child", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Sub task", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + ParentRef: &kelosv1alpha1.TaskReference{Name: "nonexistent-parent"}, + }, + Status: kelosv1alpha1.TaskStatus{ + Phase: kelosv1alpha1.TaskPhaseRunning, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(child). + WithStatusSubresource(child). + Build() + + r := &TaskReconciler{ + Client: cl, + Scheme: scheme, + } + + // Should not return an error when parent doesn't exist + if err := r.updateParentChildStatus(context.Background(), child); err != nil { + t.Fatalf("updateParentChildStatus() with missing parent returned error: %v", err) + } +} + +func TestRemoveParentChildStatus(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) + + parent := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-task", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Do something", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + }, + Status: kelosv1alpha1.TaskStatus{ + ChildTasks: []kelosv1alpha1.ChildTaskStatus{ + {Name: "child-1", Phase: kelosv1alpha1.TaskPhaseSucceeded}, + {Name: "child-2", Phase: kelosv1alpha1.TaskPhaseRunning}, + {Name: "child-3", Phase: kelosv1alpha1.TaskPhaseFailed}, + }, + }, + } + + child := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "child-2", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Sub task", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + ParentRef: &kelosv1alpha1.TaskReference{Name: "parent-task"}, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(parent, child). + WithStatusSubresource(parent, child). + Build() + + r := &TaskReconciler{ + Client: cl, + Scheme: scheme, + } + + ctx := context.Background() + + if err := r.removeParentChildStatus(ctx, child); err != nil { + t.Fatalf("removeParentChildStatus() error: %v", err) + } + + updatedParent := &kelosv1alpha1.Task{} + if err := cl.Get(ctx, client.ObjectKeyFromObject(parent), updatedParent); err != nil { + t.Fatalf("getting updated parent: %v", err) + } + + if len(updatedParent.Status.ChildTasks) != 2 { + t.Fatalf("expected 2 child task entries after removal, got %d", len(updatedParent.Status.ChildTasks)) + } + for _, child := range updatedParent.Status.ChildTasks { + if child.Name == "child-2" { + t.Errorf("child-2 should have been removed from parent status") + } + } +} + +func TestRemoveParentChildStatus_ParentNotFound(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) + + child := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "orphan-child", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Sub task", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + ParentRef: &kelosv1alpha1.TaskReference{Name: "nonexistent-parent"}, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(child). + WithStatusSubresource(child). + Build() + + r := &TaskReconciler{ + Client: cl, + Scheme: scheme, + } + + if err := r.removeParentChildStatus(context.Background(), child); err != nil { + t.Fatalf("removeParentChildStatus() with missing parent returned error: %v", err) + } +} + +func TestRemoveParentChildStatus_ChildNotInList(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) + + parent := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-task", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Do something", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + }, + Status: kelosv1alpha1.TaskStatus{ + ChildTasks: []kelosv1alpha1.ChildTaskStatus{ + {Name: "child-1", Phase: kelosv1alpha1.TaskPhaseSucceeded}, + }, + }, + } + + child := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "child-not-tracked", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "Sub task", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeAPIKey, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, + }, + ParentRef: &kelosv1alpha1.TaskReference{Name: "parent-task"}, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(parent, child). + WithStatusSubresource(parent, child). + Build() + + r := &TaskReconciler{ + Client: cl, + Scheme: scheme, + } + + ctx := context.Background() + + if err := r.removeParentChildStatus(ctx, child); err != nil { + t.Fatalf("removeParentChildStatus() error: %v", err) + } + + updatedParent := &kelosv1alpha1.Task{} + if err := cl.Get(ctx, client.ObjectKeyFromObject(parent), updatedParent); err != nil { + t.Fatalf("getting updated parent: %v", err) + } + + if len(updatedParent.Status.ChildTasks) != 1 { + t.Fatalf("expected 1 child task entry (unchanged), got %d", len(updatedParent.Status.ChildTasks)) + } +} diff --git a/internal/manifests/charts/kelos/templates/crds/task-crd.yaml b/internal/manifests/charts/kelos/templates/crds/task-crd.yaml index b5a424d24..ebe135220 100644 --- a/internal/manifests/charts/kelos/templates/crds/task-crd.yaml +++ b/internal/manifests/charts/kelos/templates/crds/task-crd.yaml @@ -40,6 +40,10 @@ spec: name: Branch priority: 1 type: string + - jsonPath: .spec.parentRef.name + name: Parent + priority: 1 + type: string - jsonPath: .spec.dependsOn name: Depends On priority: 1 @@ -156,6 +160,19 @@ spec: model: description: Model optionally overrides the default model. type: string + parentRef: + description: |- + ParentRef identifies the parent Task that spawned this child Task. + When set, the parent Task's status.childTasks is updated with this + Task's name and phase. ParentRef is immutable after creation. + properties: + name: + description: Name is the name of the referenced Task. + minLength: 1 + type: string + required: + - name + type: object podOverrides: description: PodOverrides allows customizing the agent pod configuration. properties: @@ -14085,6 +14102,28 @@ spec: status: description: TaskStatus defines the observed state of Task. properties: + childTasks: + description: |- + ChildTasks lists child Tasks spawned by this Task's agent and their + current phases. Updated by the controller when child Tasks with a + matching parentRef are created or change phase. + items: + description: ChildTaskStatus records the name and current phase + of a child Task. + properties: + name: + description: Name is the child Task's name. + type: string + phase: + description: Phase is the child Task's current phase. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map completionTime: description: CompletionTime is when the Task completed. format: date-time diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index f3f1f79af..25f6aec8a 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -666,6 +666,10 @@ spec: name: Branch priority: 1 type: string + - jsonPath: .spec.parentRef.name + name: Parent + priority: 1 + type: string - jsonPath: .spec.dependsOn name: Depends On priority: 1 @@ -782,6 +786,19 @@ spec: model: description: Model optionally overrides the default model. type: string + parentRef: + description: |- + ParentRef identifies the parent Task that spawned this child Task. + When set, the parent Task's status.childTasks is updated with this + Task's name and phase. ParentRef is immutable after creation. + properties: + name: + description: Name is the name of the referenced Task. + minLength: 1 + type: string + required: + - name + type: object podOverrides: description: PodOverrides allows customizing the agent pod configuration. properties: @@ -14711,6 +14728,28 @@ spec: status: description: TaskStatus defines the observed state of Task. properties: + childTasks: + description: |- + ChildTasks lists child Tasks spawned by this Task's agent and their + current phases. Updated by the controller when child Tasks with a + matching parentRef are created or change phase. + items: + description: ChildTaskStatus records the name and current phase + of a child Task. + properties: + name: + description: Name is the child Task's name. + type: string + phase: + description: Phase is the child Task's current phase. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map completionTime: description: CompletionTime is when the Task completed. format: date-time diff --git a/test/integration/task_test.go b/test/integration/task_test.go index 76bfe25f5..cdc5c9972 100644 --- a/test/integration/task_test.go +++ b/test/integration/task_test.go @@ -124,14 +124,16 @@ var _ = Describe("Task Controller", func() { Expect(container.Command).To(Equal([]string{"/kelos_entrypoint.sh"})) Expect(container.Args).To(Equal([]string{"Create a hello world program"})) - By("Verifying the Job has KELOS_MODEL, KELOS_AGENT_TYPE, and API key env vars") - Expect(container.Env).To(HaveLen(3)) + By("Verifying the Job has KELOS_MODEL, KELOS_AGENT_TYPE, KELOS_TASK_NAME, KELOS_TASK_NAMESPACE, and API key env vars") + Expect(container.Env).To(HaveLen(5)) Expect(container.Env[0].Name).To(Equal("KELOS_MODEL")) Expect(container.Env[0].Value).To(Equal("claude-sonnet-4-20250514")) Expect(container.Env[1].Name).To(Equal("KELOS_AGENT_TYPE")) Expect(container.Env[1].Value).To(Equal("claude-code")) - Expect(container.Env[2].Name).To(Equal("ANTHROPIC_API_KEY")) - Expect(container.Env[2].ValueFrom.SecretKeyRef.Name).To(Equal("anthropic-api-key")) + Expect(container.Env[2].Name).To(Equal("KELOS_TASK_NAME")) + Expect(container.Env[3].Name).To(Equal("KELOS_TASK_NAMESPACE")) + Expect(container.Env[4].Name).To(Equal("ANTHROPIC_API_KEY")) + Expect(container.Env[4].ValueFrom.SecretKeyRef.Name).To(Equal("anthropic-api-key")) By("Verifying the Job has owner reference") Expect(createdJob.OwnerReferences).To(HaveLen(1)) @@ -259,13 +261,15 @@ var _ = Describe("Task Controller", func() { Expect(container.Command).To(Equal([]string{"/kelos_entrypoint.sh"})) Expect(container.Args).To(Equal([]string{"Create a hello world program"})) - By("Verifying the Job has KELOS_AGENT_TYPE and OAuth token env vars") - Expect(container.Env).To(HaveLen(2)) + By("Verifying the Job has KELOS_AGENT_TYPE, KELOS_TASK_NAME, KELOS_TASK_NAMESPACE, and OAuth token env vars") + Expect(container.Env).To(HaveLen(4)) Expect(container.Env[0].Name).To(Equal("KELOS_AGENT_TYPE")) Expect(container.Env[0].Value).To(Equal("claude-code")) - Expect(container.Env[1].Name).To(Equal("CLAUDE_CODE_OAUTH_TOKEN")) - Expect(container.Env[1].ValueFrom.SecretKeyRef.Name).To(Equal("claude-oauth")) - Expect(container.Env[1].ValueFrom.SecretKeyRef.Key).To(Equal("CLAUDE_CODE_OAUTH_TOKEN")) + Expect(container.Env[1].Name).To(Equal("KELOS_TASK_NAME")) + Expect(container.Env[2].Name).To(Equal("KELOS_TASK_NAMESPACE")) + Expect(container.Env[3].Name).To(Equal("CLAUDE_CODE_OAUTH_TOKEN")) + Expect(container.Env[3].ValueFrom.SecretKeyRef.Name).To(Equal("claude-oauth")) + Expect(container.Env[3].ValueFrom.SecretKeyRef.Key).To(Equal("CLAUDE_CODE_OAUTH_TOKEN")) }) }) @@ -1228,8 +1232,8 @@ var _ = Describe("Task Controller", func() { Expect(mainContainer.Command).To(Equal([]string{"/kelos_entrypoint.sh"})) Expect(mainContainer.Args).To(Equal([]string{"Create a PR"})) - By("Verifying the main container has KELOS_AGENT_TYPE, ANTHROPIC_API_KEY, KELOS_BASE_BRANCH, GITHUB_TOKEN, GH_TOKEN, GH_CONFIG_DIR, and KELOS_GITHUB_TOKEN_FILE env vars") - Expect(mainContainer.Env).To(HaveLen(7)) + By("Verifying the main container has KELOS_AGENT_TYPE, KELOS_TASK_NAME, KELOS_TASK_NAMESPACE, ANTHROPIC_API_KEY, KELOS_BASE_BRANCH, GITHUB_TOKEN, GH_TOKEN, GH_CONFIG_DIR, and KELOS_GITHUB_TOKEN_FILE env vars") + Expect(mainContainer.Env).To(HaveLen(9)) mainEnv := map[string]corev1.EnvVar{} for _, envVar := range mainContainer.Env { mainEnv[envVar.Name] = envVar @@ -1710,11 +1714,13 @@ var _ = Describe("Task Controller", func() { Expect(container.Args).To(Equal([]string{"Fix the bug"})) By("Verifying KELOS_MODEL and KELOS_AGENT_TYPE are set") - Expect(container.Env).To(HaveLen(4)) + Expect(container.Env).To(HaveLen(6)) Expect(container.Env[0].Name).To(Equal("KELOS_MODEL")) Expect(container.Env[0].Value).To(Equal("gpt-4")) Expect(container.Env[1].Name).To(Equal("KELOS_AGENT_TYPE")) Expect(container.Env[1].Value).To(Equal("claude-code")) + Expect(container.Env[2].Name).To(Equal("KELOS_TASK_NAME")) + Expect(container.Env[3].Name).To(Equal("KELOS_TASK_NAMESPACE")) By("Verifying workspace volume mount and working dir") Expect(container.VolumeMounts).To(HaveLen(1)) @@ -1931,15 +1937,17 @@ var _ = Describe("Task Controller", func() { Expect(container.Command).To(Equal([]string{"/kelos_entrypoint.sh"})) Expect(container.Args).To(Equal([]string{"Fix the bug"})) - By("Verifying the Job has KELOS_MODEL, KELOS_AGENT_TYPE, and CODEX_API_KEY env vars") - Expect(container.Env).To(HaveLen(3)) + By("Verifying the Job has KELOS_MODEL, KELOS_AGENT_TYPE, KELOS_TASK_NAME, KELOS_TASK_NAMESPACE, and CODEX_API_KEY env vars") + Expect(container.Env).To(HaveLen(5)) Expect(container.Env[0].Name).To(Equal("KELOS_MODEL")) Expect(container.Env[0].Value).To(Equal("gpt-4.1")) Expect(container.Env[1].Name).To(Equal("KELOS_AGENT_TYPE")) Expect(container.Env[1].Value).To(Equal("codex")) - Expect(container.Env[2].Name).To(Equal("CODEX_API_KEY")) - Expect(container.Env[2].ValueFrom.SecretKeyRef.Name).To(Equal("codex-api-key")) - Expect(container.Env[2].ValueFrom.SecretKeyRef.Key).To(Equal("CODEX_API_KEY")) + Expect(container.Env[2].Name).To(Equal("KELOS_TASK_NAME")) + Expect(container.Env[3].Name).To(Equal("KELOS_TASK_NAMESPACE")) + Expect(container.Env[4].Name).To(Equal("CODEX_API_KEY")) + Expect(container.Env[4].ValueFrom.SecretKeyRef.Name).To(Equal("codex-api-key")) + Expect(container.Env[4].ValueFrom.SecretKeyRef.Key).To(Equal("CODEX_API_KEY")) By("Verifying the Job has owner reference") Expect(createdJob.OwnerReferences).To(HaveLen(1)) @@ -2023,15 +2031,17 @@ var _ = Describe("Task Controller", func() { Expect(mainContainer.Command).To(Equal([]string{"/kelos_entrypoint.sh"})) Expect(mainContainer.Args).To(Equal([]string{"Refactor the module"})) - By("Verifying the main container has KELOS_AGENT_TYPE, CODEX_API_KEY, and KELOS_BASE_BRANCH env vars") - Expect(mainContainer.Env).To(HaveLen(3)) + By("Verifying the main container has KELOS_AGENT_TYPE, KELOS_TASK_NAME, KELOS_TASK_NAMESPACE, CODEX_API_KEY, and KELOS_BASE_BRANCH env vars") + Expect(mainContainer.Env).To(HaveLen(5)) Expect(mainContainer.Env[0].Name).To(Equal("KELOS_AGENT_TYPE")) Expect(mainContainer.Env[0].Value).To(Equal("codex")) - Expect(mainContainer.Env[1].Name).To(Equal("CODEX_API_KEY")) - Expect(mainContainer.Env[1].ValueFrom.SecretKeyRef.Name).To(Equal("codex-api-key")) - Expect(mainContainer.Env[1].ValueFrom.SecretKeyRef.Key).To(Equal("CODEX_API_KEY")) - Expect(mainContainer.Env[2].Name).To(Equal("KELOS_BASE_BRANCH")) - Expect(mainContainer.Env[2].Value).To(Equal("main")) + Expect(mainContainer.Env[1].Name).To(Equal("KELOS_TASK_NAME")) + Expect(mainContainer.Env[2].Name).To(Equal("KELOS_TASK_NAMESPACE")) + Expect(mainContainer.Env[3].Name).To(Equal("CODEX_API_KEY")) + Expect(mainContainer.Env[3].ValueFrom.SecretKeyRef.Name).To(Equal("codex-api-key")) + Expect(mainContainer.Env[3].ValueFrom.SecretKeyRef.Key).To(Equal("CODEX_API_KEY")) + Expect(mainContainer.Env[4].Name).To(Equal("KELOS_BASE_BRANCH")) + Expect(mainContainer.Env[4].Value).To(Equal("main")) By("Verifying the init container") Expect(createdJob.Spec.Template.Spec.InitContainers).To(HaveLen(1)) @@ -2101,12 +2111,14 @@ var _ = Describe("Task Controller", func() { By("Verifying the Job has CODEX_AUTH_JSON env var") container := createdJob.Spec.Template.Spec.Containers[0] Expect(container.Name).To(Equal(kelos.AgentContainerName)) - Expect(container.Env).To(HaveLen(2)) + Expect(container.Env).To(HaveLen(4)) Expect(container.Env[0].Name).To(Equal("KELOS_AGENT_TYPE")) Expect(container.Env[0].Value).To(Equal("codex")) - Expect(container.Env[1].Name).To(Equal("CODEX_AUTH_JSON")) - Expect(container.Env[1].ValueFrom.SecretKeyRef.Name).To(Equal("codex-oauth-secret")) - Expect(container.Env[1].ValueFrom.SecretKeyRef.Key).To(Equal("CODEX_AUTH_JSON")) + Expect(container.Env[1].Name).To(Equal("KELOS_TASK_NAME")) + Expect(container.Env[2].Name).To(Equal("KELOS_TASK_NAMESPACE")) + Expect(container.Env[3].Name).To(Equal("CODEX_AUTH_JSON")) + Expect(container.Env[3].ValueFrom.SecretKeyRef.Name).To(Equal("codex-oauth-secret")) + Expect(container.Env[3].ValueFrom.SecretKeyRef.Key).To(Equal("CODEX_AUTH_JSON")) }) }) @@ -2189,15 +2201,17 @@ var _ = Describe("Task Controller", func() { Expect(container.Command).To(Equal([]string{"/kelos_entrypoint.sh"})) Expect(container.Args).To(Equal([]string{"Fix the bug"})) - By("Verifying the Job has KELOS_MODEL, KELOS_AGENT_TYPE, and OPENCODE_API_KEY env vars") - Expect(container.Env).To(HaveLen(3)) + By("Verifying the Job has KELOS_MODEL, KELOS_AGENT_TYPE, KELOS_TASK_NAME, KELOS_TASK_NAMESPACE, and OPENCODE_API_KEY env vars") + Expect(container.Env).To(HaveLen(5)) Expect(container.Env[0].Name).To(Equal("KELOS_MODEL")) Expect(container.Env[0].Value).To(Equal("anthropic/claude-sonnet-4-20250514")) Expect(container.Env[1].Name).To(Equal("KELOS_AGENT_TYPE")) Expect(container.Env[1].Value).To(Equal("opencode")) - Expect(container.Env[2].Name).To(Equal("OPENCODE_API_KEY")) - Expect(container.Env[2].ValueFrom.SecretKeyRef.Name).To(Equal("opencode-api-key")) - Expect(container.Env[2].ValueFrom.SecretKeyRef.Key).To(Equal("OPENCODE_API_KEY")) + Expect(container.Env[2].Name).To(Equal("KELOS_TASK_NAME")) + Expect(container.Env[3].Name).To(Equal("KELOS_TASK_NAMESPACE")) + Expect(container.Env[4].Name).To(Equal("OPENCODE_API_KEY")) + Expect(container.Env[4].ValueFrom.SecretKeyRef.Name).To(Equal("opencode-api-key")) + Expect(container.Env[4].ValueFrom.SecretKeyRef.Key).To(Equal("OPENCODE_API_KEY")) By("Verifying the Job has owner reference") Expect(createdJob.OwnerReferences).To(HaveLen(1))