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
30 changes: 30 additions & 0 deletions api/v1alpha1/task_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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`

Expand Down
40 changes: 40 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions internal/controller/job_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions internal/controller/job_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
87 changes: 87 additions & 0 deletions internal/controller/task_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Comment thread
knechtionscoding marked this conversation as resolved.
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)
})
}
Loading
Loading