feat: complete acp-go-sdk integration with task state updates
- Use acp-go-sdk for all ACP communication in agentctl - Mark agent as completed when Prompt() returns successfully - Update task state to COMPLETED when agent completes - Update task state to FAILED when agent fails and retries exhausted - Enhanced E2E test to verify agent actually creates files - E2E test now validates full flow: task creation -> agent work -> file creation -> task completion
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/docker"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// MockDockerClient implements a mock for the docker.Client for testing
|
||||
type MockDockerClient struct {
|
||||
CreateContainerFn func(ctx context.Context, cfg docker.ContainerConfig) (string, error)
|
||||
StartContainerFn func(ctx context.Context, containerID string) error
|
||||
StopContainerFn func(ctx context.Context, containerID string, timeout time.Duration) error
|
||||
KillContainerFn func(ctx context.Context, containerID string, signal string) error
|
||||
RemoveContainerFn func(ctx context.Context, containerID string, force bool) error
|
||||
GetContainerInfoFn func(ctx context.Context, containerID string) (*docker.ContainerInfo, error)
|
||||
GetContainerLogsFn func(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error)
|
||||
ListContainersFn func(ctx context.Context, labels map[string]string) ([]docker.ContainerInfo, error)
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) CreateContainer(ctx context.Context, cfg docker.ContainerConfig) (string, error) {
|
||||
if m.CreateContainerFn != nil {
|
||||
return m.CreateContainerFn(ctx, cfg)
|
||||
}
|
||||
return "mock-container-id", nil
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) StartContainer(ctx context.Context, containerID string) error {
|
||||
if m.StartContainerFn != nil {
|
||||
return m.StartContainerFn(ctx, containerID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error {
|
||||
if m.StopContainerFn != nil {
|
||||
return m.StopContainerFn(ctx, containerID, timeout)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) KillContainer(ctx context.Context, containerID string, signal string) error {
|
||||
if m.KillContainerFn != nil {
|
||||
return m.KillContainerFn(ctx, containerID, signal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) RemoveContainer(ctx context.Context, containerID string, force bool) error {
|
||||
if m.RemoveContainerFn != nil {
|
||||
return m.RemoveContainerFn(ctx, containerID, force)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) GetContainerInfo(ctx context.Context, containerID string) (*docker.ContainerInfo, error) {
|
||||
if m.GetContainerInfoFn != nil {
|
||||
return m.GetContainerInfoFn(ctx, containerID)
|
||||
}
|
||||
return &docker.ContainerInfo{ID: containerID, State: "running"}, nil
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) {
|
||||
if m.GetContainerLogsFn != nil {
|
||||
return m.GetContainerLogsFn(ctx, containerID, follow, tail)
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("test logs")), nil
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) ListContainers(ctx context.Context, labels map[string]string) ([]docker.ContainerInfo, error) {
|
||||
if m.ListContainersFn != nil {
|
||||
return m.ListContainersFn(ctx, labels)
|
||||
}
|
||||
return []docker.ContainerInfo{}, nil
|
||||
}
|
||||
|
||||
// MockEventBus implements bus.EventBus for testing
|
||||
type MockEventBus struct {
|
||||
PublishedEvents []*bus.Event
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Publish(ctx context.Context, subject string, event *bus.Event) error {
|
||||
m.PublishedEvents = append(m.PublishedEvents, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockEventBus) Close() {}
|
||||
|
||||
func (m *MockEventBus) IsConnected() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func newTestLogger() *logger.Logger {
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{
|
||||
Level: "error",
|
||||
Format: "json",
|
||||
})
|
||||
return log
|
||||
}
|
||||
|
||||
func newTestRegistry() *registry.Registry {
|
||||
log := newTestLogger()
|
||||
reg := registry.NewRegistry(log)
|
||||
reg.LoadDefaults()
|
||||
return reg
|
||||
}
|
||||
|
||||
// managerWithMock creates a manager with a mock docker client
|
||||
// We need to use reflection or a wrapper to inject the mock
|
||||
type testManager struct {
|
||||
*Manager
|
||||
mockDocker *MockDockerClient
|
||||
}
|
||||
|
||||
func newTestManager(mockDocker *MockDockerClient, eventBus bus.EventBus) *testManager {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
|
||||
// Create a real manager first
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
return &testManager{
|
||||
Manager: mgr,
|
||||
mockDocker: mockDocker,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
if mgr == nil {
|
||||
t.Fatal("expected non-nil manager")
|
||||
}
|
||||
if len(mgr.instances) != 0 {
|
||||
t.Errorf("expected empty instances, got %d", len(mgr.instances))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_GetInstance(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
// Manually add an instance for testing
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
AgentType: "test-agent",
|
||||
ContainerID: "container-123",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.byTask[instance.TaskID] = instance.ID
|
||||
mgr.byContainer[instance.ContainerID] = instance.ID
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Test GetInstance
|
||||
got, found := mgr.GetInstance("test-instance-id")
|
||||
if !found {
|
||||
t.Fatal("expected to find instance")
|
||||
}
|
||||
if got.ID != instance.ID {
|
||||
t.Errorf("expected ID %q, got %q", instance.ID, got.ID)
|
||||
}
|
||||
|
||||
// Test not found
|
||||
_, found = mgr.GetInstance("non-existent")
|
||||
if found {
|
||||
t.Error("expected not to find instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_GetInstanceByTaskID(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
AgentType: "test-agent",
|
||||
ContainerID: "container-123",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.byTask[instance.TaskID] = instance.ID
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Test GetInstanceByTaskID
|
||||
got, found := mgr.GetInstanceByTaskID("test-task-id")
|
||||
if !found {
|
||||
t.Fatal("expected to find instance")
|
||||
}
|
||||
if got.TaskID != instance.TaskID {
|
||||
t.Errorf("expected TaskID %q, got %q", instance.TaskID, got.TaskID)
|
||||
}
|
||||
|
||||
// Test not found
|
||||
_, found = mgr.GetInstanceByTaskID("non-existent")
|
||||
if found {
|
||||
t.Error("expected not to find instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_GetInstanceByContainerID(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
AgentType: "test-agent",
|
||||
ContainerID: "container-123",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.byContainer[instance.ContainerID] = instance.ID
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Test GetInstanceByContainerID
|
||||
got, found := mgr.GetInstanceByContainerID("container-123")
|
||||
if !found {
|
||||
t.Fatal("expected to find instance")
|
||||
}
|
||||
if got.ContainerID != instance.ContainerID {
|
||||
t.Errorf("expected ContainerID %q, got %q", instance.ContainerID, got.ContainerID)
|
||||
}
|
||||
|
||||
// Test not found
|
||||
_, found = mgr.GetInstanceByContainerID("non-existent")
|
||||
if found {
|
||||
t.Error("expected not to find instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_ListInstances(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
// Empty list
|
||||
list := mgr.ListInstances()
|
||||
if len(list) != 0 {
|
||||
t.Errorf("expected empty list, got %d", len(list))
|
||||
}
|
||||
|
||||
// Add instances
|
||||
mgr.mu.Lock()
|
||||
mgr.instances["instance-1"] = &AgentInstance{ID: "instance-1", TaskID: "task-1", Status: v1.AgentStatusRunning}
|
||||
mgr.instances["instance-2"] = &AgentInstance{ID: "instance-2", TaskID: "task-2", Status: v1.AgentStatusCompleted}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
list = mgr.ListInstances()
|
||||
if len(list) != 2 {
|
||||
t.Errorf("expected 2 instances, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_UpdateStatus(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
Status: v1.AgentStatusRunning,
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Test UpdateStatus
|
||||
err := mgr.UpdateStatus("test-instance-id", v1.AgentStatusCompleted)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mgr.GetInstance("test-instance-id")
|
||||
if got.Status != v1.AgentStatusCompleted {
|
||||
t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status)
|
||||
}
|
||||
|
||||
// Test not found
|
||||
err = mgr.UpdateStatus("non-existent", v1.AgentStatusCompleted)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_UpdateProgress(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
Status: v1.AgentStatusRunning,
|
||||
Progress: 0,
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Test UpdateProgress
|
||||
err := mgr.UpdateProgress("test-instance-id", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mgr.GetInstance("test-instance-id")
|
||||
if got.Progress != 50 {
|
||||
t.Errorf("expected progress 50, got %d", got.Progress)
|
||||
}
|
||||
|
||||
// Test not found
|
||||
err = mgr.UpdateProgress("non-existent", 50)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_MarkCompleted_Success(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
AgentType: "test-agent",
|
||||
ContainerID: "container-123",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: time.Now(),
|
||||
Progress: 50,
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Mark as completed successfully (exit code 0)
|
||||
err := mgr.MarkCompleted("test-instance-id", 0, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mgr.GetInstance("test-instance-id")
|
||||
if got.Status != v1.AgentStatusCompleted {
|
||||
t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status)
|
||||
}
|
||||
if got.Progress != 100 {
|
||||
t.Errorf("expected progress 100, got %d", got.Progress)
|
||||
}
|
||||
if got.FinishedAt == nil {
|
||||
t.Error("expected FinishedAt to be set")
|
||||
}
|
||||
if got.ExitCode == nil || *got.ExitCode != 0 {
|
||||
t.Errorf("expected exit code 0, got %v", got.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_MarkCompleted_Failure(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
AgentType: "test-agent",
|
||||
ContainerID: "container-123",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Mark as failed
|
||||
err := mgr.MarkCompleted("test-instance-id", 1, "process failed")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mgr.GetInstance("test-instance-id")
|
||||
if got.Status != v1.AgentStatusFailed {
|
||||
t.Errorf("expected status %v, got %v", v1.AgentStatusFailed, got.Status)
|
||||
}
|
||||
if got.ErrorMessage != "process failed" {
|
||||
t.Errorf("expected error message 'process failed', got %q", got.ErrorMessage)
|
||||
}
|
||||
if got.ExitCode == nil || *got.ExitCode != 1 {
|
||||
t.Errorf("expected exit code 1, got %v", got.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_MarkCompleted_NotFound(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
err := mgr.MarkCompleted("non-existent", 0, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_RemoveInstance(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
instance := &AgentInstance{
|
||||
ID: "test-instance-id",
|
||||
TaskID: "test-task-id",
|
||||
ContainerID: "container-123",
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.instances[instance.ID] = instance
|
||||
mgr.byTask[instance.TaskID] = instance.ID
|
||||
mgr.byContainer[instance.ContainerID] = instance.ID
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Remove instance
|
||||
mgr.RemoveInstance("test-instance-id")
|
||||
|
||||
// Verify it's gone from all maps
|
||||
if _, found := mgr.GetInstance("test-instance-id"); found {
|
||||
t.Error("instance should be removed from instances map")
|
||||
}
|
||||
if _, found := mgr.GetInstanceByTaskID("test-task-id"); found {
|
||||
t.Error("instance should be removed from byTask map")
|
||||
}
|
||||
if _, found := mgr.GetInstanceByContainerID("container-123"); found {
|
||||
t.Error("instance should be removed from byContainer map")
|
||||
}
|
||||
|
||||
// Remove non-existent should not panic
|
||||
mgr.RemoveInstance("non-existent")
|
||||
}
|
||||
|
||||
func TestManager_StartStop(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := newTestRegistry()
|
||||
eventBus := &MockEventBus{}
|
||||
mgr := NewManager(nil, reg, eventBus, log)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Start
|
||||
err := mgr.Start(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error starting manager: %v", err)
|
||||
}
|
||||
|
||||
// Test Stop
|
||||
err = mgr.Stop()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error stopping manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user