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
23 changes: 23 additions & 0 deletions go/core/pkg/sandboxbackend/substrate/agent_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,36 @@ func (p *Lifecycle) buildSandboxAgentActorTemplate(
Path: "/.well-known/agent-card.json",
Port: substrateKagentListenPort,
},
// Must match ActorTemplateSpec's ContainerReadyz.TimeoutSeconds
// +kubebuilder:default=30 (actortemplate_types.go). Leaving this
// unset builds a spec with TimeoutSeconds=0, but the apiserver
// applies the CRD default (30) to whatever gets stored — so every
// freshly-rebuilt "desired" spec permanently disagrees with the
// "existing" one just fetched back from the cluster.
// actorTemplateSpecEqual (apiequality.Semantic.DeepEqual) then
// sees 0 != 30 on every reconcile, reconcileActorTemplate treats
// it as spec drift, and deletes+recreates the ActorTemplate (and
// its golden actor) in an infinite loop. Setting it explicitly
// here keeps "desired" and "existing" in agreement.
TimeoutSeconds: 30,
},
}},
WorkerSelector: workerSelectorForPool(wpKey),
SnapshotsConfig: atev1alpha1.SnapshotsConfig{
Location: sandboxAgentSnapshotsLocation(sa),
OnPause: atev1alpha1.SnapshotScopeFull,
OnCommit: atev1alpha1.SnapshotScopeFull,
// Same class of bug as ContainerReadyz.TimeoutSeconds above:
// OnResume is a plain (non-pointer) struct, so Go's encoding/json
// omitempty never actually omits it — "desired" always serializes
// onResume:{}, and the apiserver's structural-schema defaulting then
// fills in fromData: "ColdBoot" (+kubebuilder:default=ColdBoot,
// actortemplate_types.go) on the *stored* object. Leaving this
// unset here left desired.OnResume.FromData="" permanently
// disagreeing with existing's server-defaulted "ColdBoot", which
// alone was enough to keep the delete+recreate loop going even
// after the TimeoutSeconds fix.
OnResume: atev1alpha1.OnResumeConfig{FromData: atev1alpha1.ResumeSourceColdBoot},
},
}
applyDurableDirSessionStore(&spec)
Expand Down
11 changes: 11 additions & 0 deletions go/core/pkg/sandboxbackend/substrate/agent_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,17 @@ func TestBuildSandboxAgentActorTemplate(t *testing.T) {
require.NotNil(t, c.Readyz.HTTPGet)
require.Equal(t, "/.well-known/agent-card.json", c.Readyz.HTTPGet.Path)
require.Equal(t, substrateKagentListenPort, c.Readyz.HTTPGet.Port)
// TimeoutSeconds must mirror ActorTemplateSpec's own +kubebuilder:default=30:
// it's a non-pointer field, so leaving it unset here would serialize a desired
// spec with TimeoutSeconds=0 that permanently disagrees with the apiserver's
// defaulted stored value, driving reconcileActorTemplate into a delete+recreate
// loop (see the TimeoutSeconds field comment above for the full mechanism).
require.Equal(t, int32(30), c.Readyz.TimeoutSeconds)

// OnResume must mirror SnapshotsConfig's own +kubebuilder:default=ColdBoot for the
// same reason: it's a non-pointer struct, so an unset value here would leave the
// desired spec permanently disagreeing with the apiserver-defaulted stored spec.
require.Equal(t, atev1alpha1.ResumeSourceColdBoot, tmpl.Spec.SnapshotsConfig.OnResume.FromData)

names := actorEnvNames(c.Env)
require.True(t, names["KAGENT_NAME"], "KAGENT_NAME must be a literal env var")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ func (p *Lifecycle) buildActorTemplate(ctx context.Context, ah *v1alpha3.AgentHa
// re-creates the ActorTemplate every reconcile in a hot loop.
OnPause: atev1alpha1.SnapshotScopeFull,
OnCommit: atev1alpha1.SnapshotScopeFull,
// OnResume was missing here too: it's a non-pointer struct, so
// encoding/json's omitempty never actually omits it, the apiserver
// defaults its fromData to "ColdBoot" on the stored object
// regardless, and an unset OnResume here left desired at the Go
// zero value ("") forever disagreeing with it — same hot loop via
// a different field.
OnResume: atev1alpha1.OnResumeConfig{FromData: atev1alpha1.ResumeSourceColdBoot},
},
},
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package substrate

import (
"context"
"testing"

atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"github.com/kagent-dev/kagent/go/api/v1alpha3"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)

// TestBuildActorTemplate exercises ActorTemplate generation for an AgentHarness (the
// non-SandboxAgent path), asserting the SnapshotsConfig mirrors substrate's CRD defaults
// exactly the way buildSandboxAgentActorTemplate does (see agent_lifecycle_test.go's
// TestBuildSandboxAgentActorTemplate for the equivalent SandboxAgent-side coverage).
func TestBuildActorTemplate(t *testing.T) {
t.Parallel()

const pinnedImage = "registry.example/kagent-dev/kagent/app@sha256:2222222222222222222222222222222222222222222222222222222222222222"
wpKey := types.NamespacedName{Namespace: "kagent", Name: "kagent-default"}

ah := &v1alpha3.AgentHarness{
ObjectMeta: metav1.ObjectMeta{Name: "my-harness", Namespace: "kagent"},
Spec: v1alpha3.AgentHarnessSpec{
Backend: v1alpha3.AgentHarnessBackendHermes,
Substrate: &v1alpha3.AgentHarnessSubstrateSpec{
WorkloadImage: pinnedImage,
},
},
}

p := newTestLifecycle(t)
tmpl, err := p.buildActorTemplate(context.Background(), ah, wpKey)
require.NoError(t, err)

require.Len(t, tmpl.Spec.Containers, 1)
c := tmpl.Spec.Containers[0]
require.Equal(t, pinnedImage, c.Image, "ActorTemplate must use the digest-pinned image")
require.Equal(t, wpKey.Name, tmpl.Spec.WorkerSelector.MatchLabels["kagent.dev/worker-pool"])

// SnapshotsConfig must mirror substrate's CRD defaults exactly, or kagent's spec-drift
// check (apiequality.Semantic.DeepEqual in actorTemplateSpecEqual) will treat the
// apiserver-defaulted stored spec as permanently different from the freshly-rebuilt
// desired spec, and reconcileActorTemplate will delete+recreate the ActorTemplate (and
// its golden actor) on every single reconcile.
require.Equal(t, atev1alpha1.SnapshotScopeFull, tmpl.Spec.SnapshotsConfig.OnPause)
require.Equal(t, atev1alpha1.SnapshotScopeFull, tmpl.Spec.SnapshotsConfig.OnCommit)
require.Equal(t, atev1alpha1.ResumeSourceColdBoot, tmpl.Spec.SnapshotsConfig.OnResume.FromData)
}
Loading