diff --git a/agent/robot/TODO.md b/agent/robot/TODO.md index 52949839..b218988d 100644 --- a/agent/robot/TODO.md +++ b/agent/robot/TODO.md @@ -228,7 +228,26 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) - [x] 15 test cases covering all edge cases - [x] All tests passing -### 3.3 Trigger Implementation +### ✅ 3.3 Manager Implementation (COMPLETE) + +> **Note:** Manager is the scheduling core, depends on completed Cache and Pool. + +- [x] `manager/manager.go` - Manager struct + - [x] `Start()` - load cache, start pool, start ticker goroutine + - [x] `Stop()` - graceful shutdown (wait for running, drain queue) + - [x] `Tick()` - main loop: + 1. Get all cached robots + 2. For each robot with clock trigger enabled + 3. Check if should execute (times/interval/daemon modes) + 4. Submit to pool + - [x] `TriggerManual()` - manual trigger for testing/API + - [x] Clock modes: times, interval, daemon + - [x] Day matching for times mode + - [x] Timezone handling + - [x] Skip paused/error/maintenance robots +- [x] Test: manager start/stop, tick cycle, manual trigger, clock modes, goroutine leak + +### 3.4 Trigger Implementation - [ ] `trigger/trigger.go` - trigger dispatcher (routes to clock/intervene/event) - [ ] `trigger/clock.go` - clock trigger @@ -248,15 +267,6 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) - [ ] Cancel/Stop execution - [ ] Test: clock matching (all modes), intervention handling, event dispatch -### 3.4 Dedup Implementation - -- [ ] `dedup/dedup.go` - Dedup struct -- [ ] `dedup/fast.go` - fast in-memory time-window dedup - - [ ] Key: `memberID:triggerType:window` - - [ ] Check before submit - - [ ] Mark after submit -- [ ] Test: dedup check/mark, window expiry - ### 3.5 Job Integration - [ ] `job/job.go` - create job @@ -272,29 +282,17 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) - [ ] Log errors - [ ] Test: job creation, execution tracking, log writing -### 3.6 Manager Implementation +### 3.6 Executor Stub Enhancement -- [ ] `manager/manager.go` - Manager struct - - [ ] `Start()` - start ticker goroutine, start pool - - [ ] `Stop()` - graceful shutdown (wait for running, drain queue) - - [ ] `Tick()` - main loop: - 1. Get all cached robots - 2. For each robot with clock trigger enabled - 3. Check if should execute (schedule match + dedup) - 4. Submit to pool -- [ ] Test: manager start/stop, tick cycle - -### 3.7 Executor Stub - -- [ ] `executor/executor.go` - stub implementation - - [ ] `Execute()` - simulate full execution - 1. Create Execution record +- [ ] `executor/executor.go` - enhance stub implementation + - [ ] `Execute()` - simulate full execution with Job integration + 1. Create Execution record + Job 2. Update phase: P0 → P1 → P2 → P3 → P4 → P5 - 3. Sleep briefly between phases (simulate work) + 3. Log phase transitions 4. Return success with mock data -- [ ] Test: verify stub called, verify phase progression +- [ ] Test: verify stub called, verify phase progression, verify job logs -### 3.8 Integration Test (End-to-End Scheduling) +### 3.7 Integration Test (End-to-End Scheduling) - [ ] Create test robot in `__yao.member` with clock config - [ ] Start manager @@ -302,7 +300,6 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) - [ ] Verify: - [ ] Robot loaded to cache - [ ] Clock trigger matched - - [ ] Dedup checked - [ ] Job submitted to pool - [ ] Worker picked up job - [ ] Executor stub called @@ -490,15 +487,27 @@ Create `yao-dev-app/assistants/robot/` directory: ## Phase 11: Advanced Features -**Goal:** Implement semantic dedup, plan queue. +**Goal:** Implement dedup, semantic dedup, plan queue. -### 11.1 Semantic Dedup +### 11.1 Fast Dedup (Time-Window) + +> **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after implementation. + +- [ ] `dedup/dedup.go` - Dedup struct +- [ ] `dedup/fast.go` - fast in-memory time-window dedup + - [ ] Key: `memberID:triggerType:window` + - [ ] Check before submit + - [ ] Mark after submit +- [ ] Integrate into Manager.Tick() +- [ ] Test: dedup check/mark, window expiry + +### 11.2 Semantic Dedup - [ ] `dedup/semantic.go` - call Dedup Agent for goal/task level dedup - [ ] Dedup Agent setup (`assistants/robot/dedup/`) - [ ] Test: semantic dedup with real LLM -### 11.2 Plan Queue +### 11.3 Plan Queue - [ ] `plan/plan.go` - plan queue implementation - [ ] Store planned tasks/goals diff --git a/agent/robot/manager/manager.go b/agent/robot/manager/manager.go index d98b96c3..79273cb0 100644 --- a/agent/robot/manager/manager.go +++ b/agent/robot/manager/manager.go @@ -1,34 +1,409 @@ package manager import ( + "context" + "fmt" + "sync" "time" + "github.com/yaoapp/yao/agent/robot/cache" + "github.com/yaoapp/yao/agent/robot/executor" + "github.com/yaoapp/yao/agent/robot/pool" "github.com/yaoapp/yao/agent/robot/types" ) -// Manager implements types.Manager interface -// This is a stub implementation for Phase 2 -type Manager struct{} +// Default configuration values +const ( + DefaultTickInterval = time.Minute // default tick interval for clock checking +) -// New creates a new manager instance -func New() *Manager { - return &Manager{} +// Config holds manager configuration +type Config struct { + TickInterval time.Duration // how often to check clock triggers (default: 1 minute) + PoolConfig *pool.Config // worker pool configuration } -// Start starts the manager and clock ticker -// Stub: returns nil (will be implemented in Phase 3) +// DefaultConfig returns default manager configuration +func DefaultConfig() *Config { + return &Config{ + TickInterval: DefaultTickInterval, + PoolConfig: pool.DefaultConfig(), + } +} + +// Manager implements types.Manager interface +// Orchestrates the robot scheduling system: Cache -> Dedup -> Pool -> Executor +type Manager struct { + config *Config + cache *cache.Cache + pool *pool.Pool + executor *executor.Executor + + // Ticker for clock trigger checking + ticker *time.Ticker + tickerDone chan struct{} + + // State + started bool + mu sync.RWMutex + + // Context for background operations + ctx context.Context + cancel context.CancelFunc +} + +// New creates a new manager instance with default configuration +func New() *Manager { + return NewWithConfig(nil) +} + +// NewWithConfig creates a new manager instance with custom configuration +func NewWithConfig(config *Config) *Manager { + if config == nil { + config = DefaultConfig() + } + + // Apply defaults for zero values + if config.TickInterval <= 0 { + config.TickInterval = DefaultTickInterval + } + + // Create components + c := cache.New() + p := pool.NewWithConfig(config.PoolConfig) + e := executor.New() + + // Wire up pool with executor + p.SetExecutor(e) + + return &Manager{ + config: config, + cache: c, + pool: p, + executor: e, + } +} + +// Start starts the manager +// 1. Load robots into cache +// 2. Start worker pool +// 3. Start clock ticker goroutine func (m *Manager) Start() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.started { + return fmt.Errorf("manager already started") + } + + // Create background context + m.ctx, m.cancel = context.WithCancel(context.Background()) + + // Load robots into cache + ctx := types.NewContext(m.ctx, nil) + if err := m.cache.Load(ctx); err != nil { + return fmt.Errorf("failed to load robots: %w", err) + } + + // Start worker pool + if err := m.pool.Start(); err != nil { + return fmt.Errorf("failed to start pool: %w", err) + } + + // Start clock ticker + m.ticker = time.NewTicker(m.config.TickInterval) + m.tickerDone = make(chan struct{}) + + go m.tickerLoop() + + // Start cache auto-refresh (every hour) + m.cache.StartAutoRefresh(ctx, nil) + + m.started = true return nil } // Stop stops the manager gracefully -// Stub: returns nil (will be implemented in Phase 3) +// 1. Stop clock ticker +// 2. Stop cache auto-refresh +// 3. Stop worker pool (waits for running jobs) func (m *Manager) Stop() error { + m.mu.Lock() + if !m.started { + m.mu.Unlock() + return nil + } + m.started = false + m.mu.Unlock() + + // Stop ticker + if m.tickerDone != nil { + close(m.tickerDone) + } + + // Stop cache auto-refresh + m.cache.StopAutoRefresh() + + // Stop pool (waits for running jobs) + if err := m.pool.Stop(); err != nil { + return fmt.Errorf("failed to stop pool: %w", err) + } + + // Cancel background context + if m.cancel != nil { + m.cancel() + } + return nil } +// tickerLoop is the main ticker goroutine +func (m *Manager) tickerLoop() { + for { + select { + case <-m.tickerDone: + m.ticker.Stop() + return + case now := <-m.ticker.C: + // Perform tick + ctx := types.NewContext(m.ctx, nil) + _ = m.Tick(ctx, now) + } + } +} + // Tick processes a clock tick -// Stub: returns nil (will be implemented in Phase 3) +// 1. Get all cached robots +// 2. For each robot with clock trigger enabled +// 3. Check if should execute based on clock config +// 4. Submit to pool func (m *Manager) Tick(ctx *types.Context, now time.Time) error { + m.mu.RLock() + if !m.started { + m.mu.RUnlock() + return nil + } + m.mu.RUnlock() + + // Get all cached robots + robots := m.cache.ListAll() + + for _, robot := range robots { + // Skip if robot is not active + if robot.Status == types.RobotPaused || robot.Status == types.RobotError || robot.Status == types.RobotMaintenance { + continue + } + + // Skip if clock trigger is disabled + if robot.Config == nil || robot.Config.Triggers == nil { + continue + } + if !robot.Config.Triggers.IsEnabled(types.TriggerClock) { + continue + } + + // Skip if no clock config + if robot.Config.Clock == nil { + continue + } + + // Check if should trigger based on clock config + if !m.shouldTrigger(robot, now) { + continue + } + + // TODO: dedup check (Phase 11.1) + // result, err := m.dedup.Check(ctx, robot.MemberID, types.TriggerClock) + // if err != nil || result == types.DedupSkip { + // continue + // } + + // Create clock context for P0 inspiration + clockCtx := types.NewClockContext(now, robot.Config.Clock.TZ) + + // Submit to pool + _, err := m.pool.Submit(ctx, robot, types.TriggerClock, clockCtx) + if err != nil { + // Log error but continue with other robots + // In production, this would be logged properly + continue + } + + // Update robot's last run time + robot.LastRun = now + } + return nil } + +// shouldTrigger checks if a robot should be triggered based on its clock config +func (m *Manager) shouldTrigger(robot *types.Robot, now time.Time) bool { + clock := robot.Config.Clock + if clock == nil { + return false + } + + // Get time in robot's timezone + loc := clock.GetLocation() + localNow := now.In(loc) + + switch clock.Mode { + case types.ClockTimes: + return m.shouldTriggerTimes(robot, clock, localNow) + case types.ClockInterval: + return m.shouldTriggerInterval(robot, clock, localNow) + case types.ClockDaemon: + return m.shouldTriggerDaemon(robot, clock, localNow) + default: + return false + } +} + +// shouldTriggerTimes checks if current time matches any configured times +// times mode: run at specific times (e.g., ["09:00", "14:00", "17:00"]) +func (m *Manager) shouldTriggerTimes(robot *types.Robot, clock *types.Clock, now time.Time) bool { + // Check day of week first + if !m.matchesDay(clock, now) { + return false + } + + // Check if current time matches any configured time + currentTime := now.Format("15:04") + for _, t := range clock.Times { + if t == currentTime { + // Check if already triggered in this minute + if !robot.LastRun.IsZero() { + lastRunMinute := robot.LastRun.In(now.Location()).Format("15:04") + if lastRunMinute == currentTime && robot.LastRun.Day() == now.Day() { + return false // Already triggered this minute today + } + } + return true + } + } + return false +} + +// shouldTriggerInterval checks if enough time has passed since last run +// interval mode: run every X duration (e.g., "30m", "2h") +func (m *Manager) shouldTriggerInterval(robot *types.Robot, clock *types.Clock, now time.Time) bool { + interval, err := time.ParseDuration(clock.Every) + if err != nil { + return false + } + + // First run if never executed + if robot.LastRun.IsZero() { + return true + } + + // Check if interval has passed + return now.Sub(robot.LastRun) >= interval +} + +// shouldTriggerDaemon checks if robot can restart immediately after last run +// daemon mode: restart immediately after each run completes +func (m *Manager) shouldTriggerDaemon(robot *types.Robot, clock *types.Clock, now time.Time) bool { + // Daemon mode: trigger if not currently running + // CanRun() checks if robot has available execution slots + return robot.CanRun() +} + +// matchesDay checks if current day matches the configured days +func (m *Manager) matchesDay(clock *types.Clock, now time.Time) bool { + // Empty days or ["*"] means all days + if len(clock.Days) == 0 { + return true + } + + for _, day := range clock.Days { + if day == "*" { + return true + } + // Match day name (Mon, Tue, Wed, Thu, Fri, Sat, Sun) + // or full name (Monday, Tuesday, etc.) + weekday := now.Weekday().String() + shortDay := weekday[:3] // Mon, Tue, etc. + if day == weekday || day == shortDay { + return true + } + } + return false +} + +// TriggerManual manually triggers a robot execution (for testing or API calls) +// This bypasses clock checking and directly submits to pool +func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) { + m.mu.RLock() + if !m.started { + m.mu.RUnlock() + return "", fmt.Errorf("manager not started") + } + m.mu.RUnlock() + + // Get robot from cache + robot := m.cache.Get(memberID) + if robot == nil { + return "", types.ErrRobotNotFound + } + + // Check robot status + if robot.Status == types.RobotPaused { + return "", types.ErrRobotPaused + } + + // Check if trigger type is enabled + if robot.Config != nil && robot.Config.Triggers != nil { + if !robot.Config.Triggers.IsEnabled(trigger) { + return "", types.ErrTriggerDisabled + } + } + + // Submit to pool + execID, err := m.pool.Submit(ctx, robot, trigger, data) + if err != nil { + return "", err + } + + return execID, nil +} + +// ==================== Getters for internal components ==================== +// These are exposed for testing and advanced use cases + +// Cache returns the internal cache +func (m *Manager) Cache() *cache.Cache { + return m.cache +} + +// Pool returns the internal pool +func (m *Manager) Pool() *pool.Pool { + return m.pool +} + +// Executor returns the internal executor +func (m *Manager) Executor() *executor.Executor { + return m.executor +} + +// IsStarted returns true if manager is started +func (m *Manager) IsStarted() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.started +} + +// Running returns number of currently running jobs +func (m *Manager) Running() int { + return m.pool.Running() +} + +// Queued returns number of queued jobs +func (m *Manager) Queued() int { + return m.pool.Queued() +} + +// CachedRobots returns number of cached robots +func (m *Manager) CachedRobots() int { + return m.cache.Count() +} diff --git a/agent/robot/manager/manager_test.go b/agent/robot/manager/manager_test.go new file mode 100644 index 00000000..ab31d49d --- /dev/null +++ b/agent/robot/manager/manager_test.go @@ -0,0 +1,769 @@ +package manager_test + +import ( + "context" + "encoding/json" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/xun/capsule" + "github.com/yaoapp/yao/agent/robot/manager" + "github.com/yaoapp/yao/agent/robot/pool" + "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/testutils" +) + +// TestManagerStartStop tests manager lifecycle +func TestManagerStartStop(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + setupTestRobots(t) + defer cleanupTestRobots(t) + + t.Run("start and stop manager", func(t *testing.T) { + m := manager.New() + + // Should not be started + assert.False(t, m.IsStarted()) + + // Start manager + err := m.Start() + assert.NoError(t, err) + assert.True(t, m.IsStarted()) + + // Robots should be loaded + assert.GreaterOrEqual(t, m.CachedRobots(), 2, "Should load at least 2 robots") + + // Stop manager + err = m.Stop() + assert.NoError(t, err) + assert.False(t, m.IsStarted()) + }) + + t.Run("double start should fail", func(t *testing.T) { + m := manager.New() + + err := m.Start() + assert.NoError(t, err) + + // Second start should fail + err = m.Start() + assert.Error(t, err) + assert.Contains(t, err.Error(), "already started") + + // Cleanup + m.Stop() + }) + + t.Run("stop without start should not panic", func(t *testing.T) { + m := manager.New() + + assert.NotPanics(t, func() { + err := m.Stop() + assert.NoError(t, err) + }) + }) +} + +// TestManagerTick tests the Tick function with different clock modes +func TestManagerTick(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + setupTestRobotsWithClockConfig(t) + defer cleanupTestRobots(t) + + t.Run("tick with times mode - matching time", func(t *testing.T) { + // Create manager with short tick interval for testing + config := &manager.Config{ + TickInterval: 100 * time.Millisecond, + PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10}, + } + m := manager.NewWithConfig(config) + + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + // Create a time that matches the configured time (09:00) + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Date(2025, 1, 15, 9, 0, 0, 0, loc) // Wednesday 09:00 + + ctx := types.NewContext(context.Background(), nil) + err = m.Tick(ctx, now) + assert.NoError(t, err) + + // Wait for job to be processed + time.Sleep(200 * time.Millisecond) + + // Check that job was submitted (may be queued or running) + // Note: The executor stub completes quickly, so we check execution count + execCount := m.Executor().ExecCount() + assert.GreaterOrEqual(t, execCount, 1, "Should have executed at least 1 job") + }) + + t.Run("tick with times mode - non-matching time", func(t *testing.T) { + config := &manager.Config{ + TickInterval: 100 * time.Millisecond, + PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10}, + } + m := manager.NewWithConfig(config) + + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + // Reset executor count + m.Executor().Reset() + + // Create a time that does NOT match (10:30) + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Date(2025, 1, 15, 10, 30, 0, 0, loc) // Wednesday 10:30 + + ctx := types.NewContext(context.Background(), nil) + err = m.Tick(ctx, now) + assert.NoError(t, err) + + // Wait a bit + time.Sleep(100 * time.Millisecond) + + // Should not have triggered (times mode robot only triggers at 09:00, 14:00) + execCount := m.Executor().ExecCount() + // Note: interval mode robot might trigger if enough time passed + // We just verify the times mode robot didn't trigger + assert.LessOrEqual(t, execCount, 1, "Times mode robot should not trigger at non-matching time") + }) + + t.Run("tick with interval mode", func(t *testing.T) { + config := &manager.Config{ + TickInterval: 100 * time.Millisecond, + PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10}, + } + m := manager.NewWithConfig(config) + + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + // Reset executor count + m.Executor().Reset() + + // First tick - should trigger interval mode robot (first run) + ctx := types.NewContext(context.Background(), nil) + now := time.Now() + err = m.Tick(ctx, now) + assert.NoError(t, err) + + // Wait for execution + time.Sleep(200 * time.Millisecond) + + // Should have at least 1 execution (interval robot first run) + execCount := m.Executor().ExecCount() + assert.GreaterOrEqual(t, execCount, 1, "Interval mode robot should trigger on first run") + }) + + t.Run("tick skips paused robots", func(t *testing.T) { + config := &manager.Config{ + TickInterval: 100 * time.Millisecond, + PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10}, + } + m := manager.NewWithConfig(config) + + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + // Get the paused robot from cache + pausedRobot := m.Cache().Get("robot_test_manager_paused") + assert.NotNil(t, pausedRobot) + assert.Equal(t, types.RobotPaused, pausedRobot.Status) + + // Reset executor count + m.Executor().Reset() + + // Tick should skip paused robot + ctx := types.NewContext(context.Background(), nil) + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Date(2025, 1, 15, 9, 0, 0, 0, loc) + err = m.Tick(ctx, now) + assert.NoError(t, err) + + // The paused robot should not have been triggered + // (we can't directly verify this, but we verify the tick completed) + }) +} + +// TestManagerTriggerManual tests manual triggering of robots +func TestManagerTriggerManual(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + setupTestRobotsWithClockConfig(t) + defer cleanupTestRobots(t) + + t.Run("trigger manual - success", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Manually trigger a robot + execID, err := m.TriggerManual(ctx, "robot_test_manager_times", types.TriggerHuman, nil) + assert.NoError(t, err) + assert.NotEmpty(t, execID) + + // Wait for execution + time.Sleep(200 * time.Millisecond) + + // Should have executed + assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1) + }) + + t.Run("trigger manual - robot not found", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Try to trigger non-existent robot + _, err = m.TriggerManual(ctx, "robot_nonexistent", types.TriggerHuman, nil) + assert.Error(t, err) + assert.Equal(t, types.ErrRobotNotFound, err) + }) + + t.Run("trigger manual - robot paused", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Try to trigger paused robot + _, err = m.TriggerManual(ctx, "robot_test_manager_paused", types.TriggerHuman, nil) + assert.Error(t, err) + assert.Equal(t, types.ErrRobotPaused, err) + }) + + t.Run("trigger manual - manager not started", func(t *testing.T) { + m := manager.New() + // Don't start manager + + ctx := types.NewContext(context.Background(), nil) + + _, err := m.TriggerManual(ctx, "robot_test_manager_times", types.TriggerHuman, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") + }) +} + +// TestManagerClockModes tests all three clock modes +func TestManagerClockModes(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + setupTestRobotsWithClockConfig(t) + defer cleanupTestRobots(t) + + t.Run("times mode - day matching", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + m.Executor().Reset() + + // Wednesday (configured day) + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Date(2025, 1, 15, 9, 0, 0, 0, loc) // Wednesday 09:00 + + ctx := types.NewContext(context.Background(), nil) + err = m.Tick(ctx, now) + assert.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1, "Should trigger on matching day") + }) + + t.Run("times mode - day not matching", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + m.Executor().Reset() + + // Saturday (not configured) + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Date(2025, 1, 18, 9, 0, 0, 0, loc) // Saturday 09:00 + + ctx := types.NewContext(context.Background(), nil) + err = m.Tick(ctx, now) + assert.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + // Times mode robot should not trigger on Saturday + // Only interval/daemon robots might trigger + }) + + t.Run("daemon mode - always triggers when idle", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + m.Executor().Reset() + + // Daemon robot should trigger whenever it can run + ctx := types.NewContext(context.Background(), nil) + now := time.Now() + + // First tick + err = m.Tick(ctx, now) + assert.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + + // Should have triggered daemon robot + assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1, "Daemon mode should trigger") + }) +} + +// TestManagerGoroutineLeak tests that manager doesn't leak goroutines +func TestManagerGoroutineLeak(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + setupTestRobots(t) + defer cleanupTestRobots(t) + + t.Run("start stop cycle should not leak goroutines", func(t *testing.T) { + // Record initial goroutine count + runtime.GC() + time.Sleep(100 * time.Millisecond) + initialGoroutines := runtime.NumGoroutine() + + // Start and stop multiple times + for i := 0; i < 5; i++ { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + + // Do some ticks + ctx := types.NewContext(context.Background(), nil) + m.Tick(ctx, time.Now()) + + time.Sleep(50 * time.Millisecond) + + err = m.Stop() + assert.NoError(t, err) + } + + // Wait for cleanup + time.Sleep(200 * time.Millisecond) + runtime.GC() + time.Sleep(100 * time.Millisecond) + + // Check goroutine count + finalGoroutines := runtime.NumGoroutine() + assert.LessOrEqual(t, finalGoroutines, initialGoroutines+2, + "Should not leak goroutines (initial: %d, final: %d)", + initialGoroutines, finalGoroutines) + }) +} + +// TestManagerComponents tests access to internal components +func TestManagerComponents(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + setupTestRobots(t) + defer cleanupTestRobots(t) + + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + t.Run("cache access", func(t *testing.T) { + cache := m.Cache() + assert.NotNil(t, cache) + + robot := cache.Get("robot_test_sales_001") + assert.NotNil(t, robot) + }) + + t.Run("pool access", func(t *testing.T) { + pool := m.Pool() + assert.NotNil(t, pool) + assert.True(t, pool.IsStarted()) + }) + + t.Run("executor access", func(t *testing.T) { + executor := m.Executor() + assert.NotNil(t, executor) + }) + + t.Run("running and queued counts", func(t *testing.T) { + running := m.Running() + queued := m.Queued() + cached := m.CachedRobots() + + assert.GreaterOrEqual(t, running, 0) + assert.GreaterOrEqual(t, queued, 0) + assert.GreaterOrEqual(t, cached, 2) + }) +} + +// ==================== Test Data Setup ==================== + +// setupTestRobots creates basic test robots (same as cache tests) +func setupTestRobots(t *testing.T) { + m := model.Select("__yao.member") + tableName := m.MetaData.Table.Name + qb := capsule.Query() + + // Robot 1: Sales Bot + robotConfig1 := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "Sales Manager", + "duties": []string{"Manage leads", "Follow up customers"}, + }, + "quota": map[string]interface{}{ + "max": 3, + "queue": 15, + "priority": 7, + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": true}, + }, + "clock": map[string]interface{}{ + "mode": "times", + "times": []string{"09:00", "14:00"}, + "days": []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + "tz": "Asia/Shanghai", + }, + } + config1JSON, _ := json.Marshal(robotConfig1) + + err := qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_sales_001", + "team_id": "team_test_cache_001", + "member_type": "robot", + "display_name": "Test Sales Bot", + "system_prompt": "You are a professional sales manager assistant.", + "status": "active", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "idle", + "robot_config": string(config1JSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_sales_001: %v", err) + } + + // Robot 2: Support Bot + robotConfig2 := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "Customer Support", + "duties": []string{"Answer questions", "Resolve issues"}, + }, + "quota": map[string]interface{}{ + "max": 2, + "queue": 10, + "priority": 5, + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": true}, + }, + "clock": map[string]interface{}{ + "mode": "interval", + "every": "1h", + }, + } + config2JSON, _ := json.Marshal(robotConfig2) + + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_support_002", + "team_id": "team_test_cache_001", + "member_type": "robot", + "display_name": "Test Support Bot", + "system_prompt": "You are a helpful customer support assistant.", + "status": "active", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "idle", + "robot_config": string(config2JSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_support_002: %v", err) + } + + // Robot 3: Inactive robot + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_inactive_003", + "team_id": "team_test_cache_001", + "member_type": "robot", + "display_name": "Test Inactive Bot", + "status": "inactive", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "paused", + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_inactive_003: %v", err) + } +} + +// setupTestRobotsWithClockConfig creates robots with specific clock configurations +func setupTestRobotsWithClockConfig(t *testing.T) { + m := model.Select("__yao.member") + tableName := m.MetaData.Table.Name + qb := capsule.Query() + + // Robot 1: Times mode (09:00, 14:00 on weekdays) + robotConfigTimes := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "Times Mode Robot", + }, + "quota": map[string]interface{}{ + "max": 2, + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": true}, + }, + "clock": map[string]interface{}{ + "mode": "times", + "times": []string{"09:00", "14:00"}, + "days": []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + "tz": "Asia/Shanghai", + }, + } + configTimesJSON, _ := json.Marshal(robotConfigTimes) + + err := qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_times", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test Times Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "idle", + "robot_config": string(configTimesJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_times: %v", err) + } + + // Robot 2: Interval mode (every 30 minutes) + robotConfigInterval := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "Interval Mode Robot", + }, + "quota": map[string]interface{}{ + "max": 2, + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": true}, + }, + "clock": map[string]interface{}{ + "mode": "interval", + "every": "30m", + }, + } + configIntervalJSON, _ := json.Marshal(robotConfigInterval) + + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_interval", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test Interval Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "idle", + "robot_config": string(configIntervalJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_interval: %v", err) + } + + // Robot 3: Daemon mode + robotConfigDaemon := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "Daemon Mode Robot", + }, + "quota": map[string]interface{}{ + "max": 2, + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": true}, + }, + "clock": map[string]interface{}{ + "mode": "daemon", + "timeout": "5m", + }, + } + configDaemonJSON, _ := json.Marshal(robotConfigDaemon) + + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_daemon", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test Daemon Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "idle", + "robot_config": string(configDaemonJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_daemon: %v", err) + } + + // Robot 4: Paused robot (should be skipped) + robotConfigPaused := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "Paused Robot", + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": true}, + }, + "clock": map[string]interface{}{ + "mode": "times", + "times": []string{"09:00"}, + "tz": "Asia/Shanghai", + }, + } + configPausedJSON, _ := json.Marshal(robotConfigPaused) + + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_paused", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test Paused Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "paused", + "robot_config": string(configPausedJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_paused: %v", err) + } + + // Robot 5: Clock disabled robot + robotConfigDisabled := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "Clock Disabled Robot", + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": false}, + }, + "clock": map[string]interface{}{ + "mode": "times", + "times": []string{"09:00"}, + }, + } + configDisabledJSON, _ := json.Marshal(robotConfigDisabled) + + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_disabled", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test Clock Disabled Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": true, + "robot_status": "idle", + "robot_config": string(configDisabledJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_disabled: %v", err) + } +} + +// cleanupTestRobots removes all test robot records +func cleanupTestRobots(t *testing.T) { + qb := capsule.Query() + m := model.Select("__yao.member") + tableName := m.MetaData.Table.Name + + // List of test robot IDs to clean up + testRobotIDs := []string{ + "robot_test_sales_001", + "robot_test_support_002", + "robot_test_inactive_003", + "robot_test_manager_times", + "robot_test_manager_interval", + "robot_test_manager_daemon", + "robot_test_manager_paused", + "robot_test_manager_disabled", + } + + for _, id := range testRobotIDs { + // Soft delete + m.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: id}, + }, + }) + // Hard delete + qb.Table(tableName).Where("member_id", id).Delete() + } +}