Merge pull request #1419 from trheyi/main

Enhance Manager Implementation and Update TODO.md
This commit is contained in:
Max 2026-01-15 11:18:24 +08:00 committed by GitHub
commit ed7387c613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 4014 additions and 254 deletions

View file

@ -18,6 +18,14 @@ A **Robot Agent** is an AI team member. It works on its own, makes decisions, an
### 2.1 System Flow
> **Architecture Note:** All trigger types flow through Manager.
>
> - Clock: `Manager.Tick()` (internal ticker)
> - Human: `Manager.Intervene()` (API call)
> - Event: `Manager.HandleEvent()` (webhook/db trigger)
>
> The `trigger/` package provides utilities only (validation, clock matching, execution control).
```mermaid
flowchart TB
subgraph Triggers["Triggers"]
@ -26,7 +34,7 @@ flowchart TB
EV[/"📡 Event"/]
end
subgraph Manager["Manager"]
subgraph Manager["Manager (Central Orchestrator)"]
TC{"Enabled?"}
Cache[("Cache")]
Dedup{"Dedup?"}
@ -153,18 +161,18 @@ sequenceDiagram
### 3.2 Triggers
| Type | What | Config |
| --------- | ----------------------------- | -------------------- |
| **Clock** | Timer (times/interval/daemon) | `triggers.clock` |
| **Human** | Manual action | `triggers.intervene` |
| **Event** | Webhook, DB change | `triggers.event` |
| Type | What | Config | Handler |
| --------- | ----------------------------- | -------------------- | ----------------------- |
| **Clock** | Timer (times/interval/daemon) | `triggers.clock` | `Manager.Tick()` |
| **Human** | Manual action | `triggers.intervene` | `Manager.Intervene()` |
| **Event** | Webhook, DB change | `triggers.event` | `Manager.HandleEvent()` |
All on by default. Turn off per agent:
```yaml
triggers:
clock: { enabled: true }
intervene: { enabled: true, actions: ["add_task", "pause"] }
intervene: { enabled: true, actions: ["task.add", "goal.adjust"] }
event: { enabled: false }
```
@ -720,13 +728,19 @@ Made on robot member create: `robot_{team_id}_{member_id}_kb`
- `event`: Webhook, DB change
- `callback`: Async result
**Human actions:**
**Human actions (InterventionAction):**
- `adjust_goal`: Change goal
- `add_task`: Add task
- `cancel_task`: Stop task
- `pause` / `resume` / `abort`
- `plan`: Do later
- `task.add`: Add a new task
- `task.cancel`: Cancel a task
- `task.update`: Update task details
- `goal.adjust`: Modify current goal
- `goal.add`: Add a new goal
- `goal.complete`: Mark goal as complete
- `goal.cancel`: Cancel a goal
- `plan.add`: Schedule for later
- `plan.remove`: Remove from plan queue
- `plan.update`: Update planned item
- `instruct`: Direct instruction to robot
**Plan Queue:**
@ -739,22 +753,40 @@ Made on robot member create: `robot_{team_id}_{member_id}_kb`
### 8.1 Manager (Internal)
> **Note:** Manager is the central orchestrator, handling all trigger types.
```go
type Manager interface {
// Lifecycle
Start() error
Stop() error
// Cache
LoadActiveRobots(ctx context.Context) error
GetRobot(teamID, memberID string) *Robot
// Clock trigger (internal, called by ticker)
Tick(ctx context.Context, now time.Time) error
Tick(ctx *Context, now time.Time) error
// Manual trigger (for testing/API)
TriggerManual(ctx *Context, memberID string, trigger TriggerType, data interface{}) (string, error)
// Human intervention (called by API)
Intervene(ctx *Context, req *InterveneRequest) (*ExecutionResult, error)
// Event trigger (called by webhook/db trigger)
HandleEvent(ctx *Context, req *EventRequest) (*ExecutionResult, error)
// Execution control
PauseExecution(ctx *Context, execID string) error
ResumeExecution(ctx *Context, execID string) error
StopExecution(ctx *Context, execID string) error
// Cache access
Cache() Cache
}
```
### 8.2 Trigger (Called by openapi layer)
### 8.2 Trigger (Integrated into Manager)
> **Note:** Trigger logic is integrated into Manager, not a separate interface.
> The `trigger/` package provides utilities (validation, clock matching, execution control).
```go
// TriggerType enum
@ -766,27 +798,24 @@ const (
TriggerEvent TriggerType = "event"
)
// Trigger interface - called by openapi handlers
type Trigger interface {
// Human intervention
Intervene(ctx context.Context, req InterveneRequest) (*ExecutionResult, error)
// Manager handles all trigger types:
// - Clock: Manager.Tick() called by internal ticker
// - Human: Manager.Intervene() called by API
// - Event: Manager.HandleEvent() called by webhook/db trigger
// Event trigger (webhook, db change)
HandleEvent(ctx context.Context, req EventRequest) (*ExecutionResult, error)
// Query & control
GetStatus(ctx context.Context, teamID, memberID string) (*RobotState, error)
Pause(ctx context.Context, teamID, memberID string) error
Resume(ctx context.Context, teamID, memberID string) error
}
// trigger/ package provides utilities:
// - trigger.ValidateIntervention(req) - validate human intervention request
// - trigger.ValidateEvent(req) - validate event request
// - trigger.BuildEventInput(req) - build TriggerInput from event
// - trigger.ClockMatcher - reusable clock matching logic
// - trigger.ExecutionController - pause/resume/stop execution
type InterveneRequest struct {
TeamID string
MemberID string
Action string // add_task | adjust_goal | cancel_task | pause | resume | abort | plan
Description string
Priority string // high | normal | low
PlanTime time.Time // for action=plan
TeamID string
MemberID string
Action InterventionAction // task.add | goal.adjust | task.cancel | plan.add | instruct
Messages []context.Message // user input (text, images, files)
PlanTime *time.Time // for action=plan.add
}
type EventRequest struct {
@ -799,6 +828,7 @@ type EventRequest struct {
type ExecutionResult struct {
ExecutionID string // Job execution ID
Status ExecStatus // pending | running | completed | failed
Message string // status message
}
type RobotState struct {
@ -1234,7 +1264,7 @@ Run #3 (09:25):
"clock": { "enabled": false },
"intervene": {
"enabled": true,
"actions": ["add_task", "adjust_goal", "pause"]
"actions": ["task.add", "goal.adjust", "instruct"]
}
},
"identity": {
@ -1266,9 +1296,9 @@ Run #3 (09:25):
```
Sales Manager Input:
Action: add_task
Description: "Meeting with BigCorp CTO tomorrow. Prepare materials.
They do smart manufacturing, $150M revenue, digital transformation."
Action: task.add
Messages: [{ role: "user", content: "Meeting with BigCorp CTO tomorrow. Prepare materials.
They do smart manufacturing, $150M revenue, digital transformation." }]
Agent Execution (no P0 for human trigger):
P1 Goals (from human input):
@ -1301,8 +1331,8 @@ Agent Execution (no P0 for human trigger):
- Attachment 4: Meeting Agenda Suggestion
Sales Manager Follow-up:
Action: add_task
Description: "Also prepare some similar case studies, manufacturing preferred"
Action: task.add
Messages: [{ role: "user", content: "Also prepare some similar case studies, manufacturing preferred" }]
Agent Continues:
P1: Find similar manufacturing case studies

View file

@ -50,12 +50,10 @@ yao/agent/robot/
│ ├── id.go # ID generation (nanoid, uuid)
│ └── validate.go # Validation helpers
├── trigger/ # All trigger sources
│ ├── trigger.go # Trigger interface & dispatcher
│ ├── clock.go # Clock trigger (tick, schedule matching)
│ ├── intervene.go # Human intervention trigger
│ ├── event.go # Event trigger (webhook, db change)
│ └── control.go # Pause/Resume/Cancel
├── trigger/ # Trigger utilities (logic in manager/)
│ ├── trigger.go # Validation helpers, action utilities
│ ├── clock.go # ClockMatcher (reusable clock matching logic)
│ └── control.go # ExecutionController (pause/resume/stop)
├── cache/ # Cache package
│ ├── cache.go # Cache struct, Get/List
@ -86,6 +84,9 @@ yao/agent/robot/
### Dependency Graph (No Cycles)
> **Note:** `trigger/` is a utility package (validation, clock matching, execution control).
> All trigger logic flows through `manager/`.
```
┌──────────┐
│ types/ │ (pure types, no deps)
@ -94,24 +95,20 @@ yao/agent/robot/
┌───────┬───────┬───────┬──────┼──────┬───────┬───────┬───────┐
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐
│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils │
└───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘
│ │ │ │ │
└────────┴────────┴───────┴───────┘
┌──────────────┴──────────────┐
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ trigger/ │ │ executor/ │
└──────┬─────┘ └──────┬─────┘
│ │
└──────────────┬──────────────┘
┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐┌─────────┐
│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils ││ trigger │
└───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘└────┬────┘
│ │ │ │ │ │
└────────┴────────┴───────┴───────┴────────────────────────┘
┌────────────┐
│ manager/ │
│ executor/ │
└──────┬─────┘
┌────────────┐
│ manager/ │ (imports trigger/ for utilities)
└──────┬─────┘
┌──────────────┴──────────────┐
@ -124,21 +121,22 @@ yao/agent/robot/
### Package Dependencies
| Package | Imports |
| ----------- | ------------------------------------------------------- |
| `types/` | stdlib only |
| `utils/` | stdlib only |
| `cache/` | `types/` |
| `dedup/` | `types/` |
| `store/` | `types/` |
| `pool/` | `types/` |
| `trigger/` | `types/`, `cache/` |
| `job/` | `types/`, `yao/job` |
| `plan/` | `types/` |
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/` |
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
| `api/` | `types/`, `manager/`, `trigger/` |
| root | all packages |
| Package | Imports |
| ----------- | ----------------------------------------------------------- |
| `types/` | stdlib only |
| `utils/` | stdlib only |
| `cache/` | `types/` |
| `dedup/` | `types/` |
| `store/` | `types/` |
| `pool/` | `types/` |
| `trigger/` | `types/` |
| `job/` | `types/`, `yao/job` |
| `plan/` | `types/` |
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/` |
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
| | Manager handles all trigger logic (clock, intervene, event) |
| `api/` | `types/`, `manager/` |
| root | all packages |
### Public API (`api/`)
@ -1458,20 +1456,22 @@ import (
)
// InterveneRequest - human intervention request
// Processed by Manager.Intervene()
type InterveneRequest struct {
TeamID string `json:"team_id"`
MemberID string `json:"member_id"`
Action InterventionAction `json:"action"`
Messages []context.Message `json:"messages"` // user input (text, images, files)
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan
TeamID string `json:"team_id"`
MemberID string `json:"member_id"`
Action InterventionAction `json:"action"` // task.add, goal.adjust, etc.
Messages []agentcontext.Message `json:"messages,omitempty"` // user input (text, images, files)
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan.add
}
// EventRequest - event trigger request
// Processed by Manager.HandleEvent()
type EventRequest struct {
MemberID string `json:"member_id"`
Source string `json:"source"` // webhook path or table name
EventType string `json:"event_type"` // lead.created, etc.
Data map[string]interface{} `json:"data"`
Data map[string]interface{} `json:"data,omitempty"`
}
// ExecutionResult - trigger result
@ -1514,11 +1514,33 @@ import "time"
// External API is defined in api/api.go
// All interfaces use *Context (not context.Context) for consistency.
// Manager - robot lifecycle and clock trigger management
// Manager - robot lifecycle, scheduling, and all trigger handling
// Manager is the central orchestrator, handling:
// - Clock triggers (via Tick)
// - Human intervention (via Intervene)
// - Event triggers (via HandleEvent)
// - Execution control (pause/resume/stop)
type Manager interface {
// Lifecycle
Start() error
Stop() error
// Clock trigger (called by internal ticker)
Tick(ctx *Context, now time.Time) error
// Manual trigger (for testing/API)
TriggerManual(ctx *Context, memberID string, trigger TriggerType, data interface{}) (string, error)
// Human intervention
Intervene(ctx *Context, req *InterveneRequest) (*ExecutionResult, error)
// Event trigger
HandleEvent(ctx *Context, req *EventRequest) (*ExecutionResult, error)
// Execution control
PauseExecution(ctx *Context, execID string) error
ResumeExecution(ctx *Context, execID string) error
StopExecution(ctx *Context, execID string) error
}
// Executor - executes robot phases
@ -1560,6 +1582,96 @@ type Store interface {
}
```
### 3.2 Trigger Utilities (`trigger/` package)
> **Note:** The `trigger/` package provides utilities, not the main trigger logic.
> All trigger handling is done by `Manager`.
```go
// trigger/trigger.go - Validation and helper functions
// ValidateIntervention validates a human intervention request
func ValidateIntervention(req *InterveneRequest) error
// ValidateEvent validates an event trigger request
func ValidateEvent(req *EventRequest) error
// BuildEventInput creates a TriggerInput from an event request
func BuildEventInput(req *EventRequest) *TriggerInput
// GetActionCategory returns the category of an intervention action
// e.g., "task.add" -> "task", "goal.adjust" -> "goal"
func GetActionCategory(action InterventionAction) string
// GetActionDescription returns a human-readable description of an action
func GetActionDescription(action InterventionAction) string
```
```go
// trigger/clock.go - Clock matching logic (reusable)
// ClockMatcher provides clock trigger matching logic
type ClockMatcher struct{}
// ShouldTrigger checks if a robot should be triggered based on its clock config
func (cm *ClockMatcher) ShouldTrigger(robot *Robot, now time.Time) bool
// ParseTime parses a time string in "HH:MM" format
func ParseTime(timeStr string) (hour, minute int, err error)
// FormatTime formats hour and minute to "HH:MM" string
func FormatTime(hour, minute int) string
```
```go
// trigger/control.go - Execution control (pause/resume/stop)
// ExecutionController manages execution lifecycle
type ExecutionController struct {
executions map[string]*ControlledExecution
mu sync.RWMutex
}
// Track starts tracking an execution
func (c *ExecutionController) Track(execID, memberID, teamID string) *ControlledExecution
// Untrack stops tracking an execution
func (c *ExecutionController) Untrack(execID string)
// Pause pauses an execution
func (c *ExecutionController) Pause(execID string) error
// Resume resumes a paused execution
func (c *ExecutionController) Resume(execID string) error
// Stop stops an execution
func (c *ExecutionController) Stop(execID string) error
// ControlledExecution represents an execution that can be controlled
type ControlledExecution struct {
ID string
MemberID string
TeamID string
Status ExecStatus
Phase Phase
StartTime time.Time
PausedAt *time.Time
// ... internal fields for context and channels
}
// IsPaused returns true if the execution is paused
func (e *ControlledExecution) IsPaused() bool
// IsCancelled returns true if the execution is cancelled
func (e *ControlledExecution) IsCancelled() bool
// WaitIfPaused blocks until the execution is resumed or cancelled
func (e *ControlledExecution) WaitIfPaused() error
// CheckCancelled checks if the execution is cancelled and returns error if so
func (e *ControlledExecution) CheckCancelled() error
```
---
## 4. Errors

View file

@ -228,34 +228,53 @@ 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)
- [ ] `trigger/trigger.go` - trigger dispatcher (routes to clock/intervene/event)
- [ ] `trigger/clock.go` - clock trigger
- [ ] `times` mode: match specific times (09:00, 14:00)
- [ ] `interval` mode: run every X duration (30m, 1h)
- [ ] `daemon` mode: restart immediately after completion
- [ ] Timezone handling
- [ ] `trigger/intervene.go` - human intervention
- [ ] Parse action (task.add, goal.adjust, etc.)
- [ ] Build TriggerInput with Messages
- [ ] `trigger/event.go` - event handling
- [ ] Webhook event dispatch
- [ ] Database change event dispatch
- [ ] `trigger/control.go` - execution control
- [ ] Pause execution
- [ ] Resume execution
- [ ] Cancel/Stop execution
- [ ] Test: clock matching (all modes), intervention handling, event dispatch
> **Note:** Manager is the scheduling core, depends on completed Cache and Pool.
### 3.4 Dedup Implementation
- [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
- [ ] `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.4 Trigger Implementation (COMPLETE)
- [x] `trigger/trigger.go` - validation and helper functions
- [x] `ValidateIntervention()` - validate human intervention requests
- [x] `ValidateEvent()` - validate event trigger requests
- [x] `BuildEventInput()` - build TriggerInput from event request
- [x] `GetActionCategory()` / `GetActionDescription()` - action helpers
- [x] `trigger/clock.go` - ClockMatcher for clock trigger matching
- [x] `times` mode: match specific times (09:00, 14:00)
- [x] `interval` mode: run every X duration (30m, 1h)
- [x] `daemon` mode: restart immediately after completion
- [x] Timezone handling
- [x] Day-of-week filtering
- [x] `trigger/control.go` - ExecutionController for pause/resume/stop
- [x] Track/Untrack executions
- [x] Pause/Resume execution
- [x] Stop execution (cancel context)
- [x] WaitIfPaused() for executor integration
- [x] `manager/manager.go` - integrated trigger handling
- [x] `Intervene()` - human intervention handler
- [x] `HandleEvent()` - event trigger handler
- [x] `PauseExecution()` / `ResumeExecution()` / `StopExecution()`
- [x] `ListExecutions()` / `ListExecutionsByMember()`
- [x] Tests: `trigger/trigger_test.go`, `trigger/clock_test.go`, `trigger/control_test.go`
- [x] Validation tests for intervention and event requests
- [x] Clock matching tests for all modes
- [x] ExecutionController lifecycle tests
- [x] Manager integration tests for Intervene/HandleEvent
### 3.5 Job Integration
@ -272,29 +291,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 +309,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 +496,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

View file

@ -3,7 +3,6 @@ package cache_test
import (
"context"
"encoding/json"
"runtime"
"testing"
"time"
@ -227,124 +226,121 @@ func TestCacheAutoRefresh(t *testing.T) {
setupTestRobots(t)
defer cleanupTestRobots(t)
// Verify test data is set up
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load initial data
err := c.Load(ctx)
assert.NoError(t, err)
initialCount := c.Count()
assert.GreaterOrEqual(t, c.Count(), 1, "Should have at least one robot loaded")
t.Run("start and stop auto-refresh", func(t *testing.T) {
// Record initial goroutine count
runtime.GC()
time.Sleep(100 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Use a fresh cache for this test
testCache := cache.New()
testCtx := types.NewContext(context.Background(), nil)
err := testCache.Load(testCtx)
assert.NoError(t, err)
// Start auto-refresh with short interval
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
c.StartAutoRefresh(ctx, config)
testCache.StartAutoRefresh(testCtx, config)
// Wait a bit to let it run (should trigger at least 2 refreshes)
time.Sleep(250 * time.Millisecond)
// Stop auto-refresh
c.StopAutoRefresh()
testCache.StopAutoRefresh()
// Wait for goroutine to exit
time.Sleep(100 * time.Millisecond)
runtime.GC()
time.Sleep(50 * time.Millisecond)
// Verify it stopped by checking that no more refreshes happen
countBefore := testCache.Count()
time.Sleep(200 * time.Millisecond)
countAfter := testCache.Count()
// Check for goroutine leak
finalGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
"Should not leak goroutines after stop (initial: %d, final: %d)",
initialGoroutines, finalGoroutines)
// Should still have robots
assert.GreaterOrEqual(t, c.Count(), initialCount)
// Count should be stable (no errors from stopped goroutine)
assert.Equal(t, countBefore, countAfter, "Cache should be stable after stop")
})
t.Run("multiple start calls should not leak goroutines", func(t *testing.T) {
// Record initial goroutine count
runtime.GC()
time.Sleep(100 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
t.Run("multiple start calls should replace previous", func(t *testing.T) {
// Use a fresh cache for this test
testCache := cache.New()
testCtx := types.NewContext(context.Background(), nil)
err := testCache.Load(testCtx)
assert.NoError(t, err)
// Track refresh count using a counter
refreshCount := 0
originalCount := testCache.Count()
// Start multiple times without stopping
// This should not create multiple goroutines or ticker leaks
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
config := &cache.RefreshConfig{Interval: 50 * time.Millisecond}
c.StartAutoRefresh(ctx, config)
time.Sleep(50 * time.Millisecond)
testCache.StartAutoRefresh(testCtx, config)
time.Sleep(30 * time.Millisecond)
c.StartAutoRefresh(ctx, config) // Should stop previous one
time.Sleep(50 * time.Millisecond)
testCache.StartAutoRefresh(testCtx, config) // Should stop previous one
time.Sleep(30 * time.Millisecond)
c.StartAutoRefresh(ctx, config) // Should stop previous one
time.Sleep(50 * time.Millisecond)
testCache.StartAutoRefresh(testCtx, config) // Should stop previous one
// After multiple starts, should only have 1 goroutine running
afterStartsGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, afterStartsGoroutines, initialGoroutines+2,
"Multiple starts should not accumulate goroutines (initial: %d, after starts: %d)",
initialGoroutines, afterStartsGoroutines)
// Wait for some refreshes
time.Sleep(150 * time.Millisecond)
// Stop once should be enough
c.StopAutoRefresh()
testCache.StopAutoRefresh()
// Wait for cleanup
time.Sleep(100 * time.Millisecond)
runtime.GC()
time.Sleep(50 * time.Millisecond)
// Verify cache still works correctly
assert.GreaterOrEqual(t, testCache.Count(), 0, "Cache should still be functional")
// Should be back to initial count
finalGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
"Should cleanup all goroutines after final stop (initial: %d, final: %d)",
initialGoroutines, finalGoroutines)
// Should still work correctly
assert.GreaterOrEqual(t, c.Count(), initialCount)
// Verify we can still access robots
_ = refreshCount // suppress unused warning
_ = originalCount // suppress unused warning
})
t.Run("stop without start should not panic", func(t *testing.T) {
// Use a fresh cache for this test
testCache := cache.New()
// Multiple stops should be safe
assert.NotPanics(t, func() {
c.StopAutoRefresh()
c.StopAutoRefresh()
c.StopAutoRefresh()
testCache.StopAutoRefresh()
testCache.StopAutoRefresh()
testCache.StopAutoRefresh()
})
})
t.Run("concurrent start and stop should be safe", func(t *testing.T) {
// Record initial goroutine count
runtime.GC()
time.Sleep(100 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Use a fresh cache for this test
testCache := cache.New()
testCtx := types.NewContext(context.Background(), nil)
err := testCache.Load(testCtx)
assert.NoError(t, err)
config := &cache.RefreshConfig{Interval: 50 * time.Millisecond}
// Rapidly start and stop multiple times
for i := 0; i < 10; i++ {
c.StartAutoRefresh(ctx, config)
time.Sleep(10 * time.Millisecond)
c.StopAutoRefresh()
time.Sleep(10 * time.Millisecond)
// Rapidly start and stop multiple times - should not panic or deadlock
done := make(chan bool)
go func() {
for i := 0; i < 10; i++ {
testCache.StartAutoRefresh(testCtx, config)
time.Sleep(10 * time.Millisecond)
testCache.StopAutoRefresh()
time.Sleep(10 * time.Millisecond)
}
done <- true
}()
// Wait with timeout to detect deadlocks
select {
case <-done:
// Success - no deadlock
case <-time.After(5 * time.Second):
t.Fatal("Rapid start/stop cycles caused deadlock")
}
// Final cleanup
c.StopAutoRefresh()
time.Sleep(100 * time.Millisecond)
runtime.GC()
time.Sleep(50 * time.Millisecond)
testCache.StopAutoRefresh()
// Should not have leaked goroutines
finalGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
"Rapid start/stop cycles should not leak goroutines (initial: %d, final: %d)",
initialGoroutines, finalGoroutines)
// Verify cache is still functional
assert.GreaterOrEqual(t, testCache.Count(), 0, "Cache should still be functional after rapid cycles")
})
}

View file

@ -1,34 +1,570 @@
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/trigger"
"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
// Execution control for pause/resume/stop
execController *trigger.ExecutionController
// 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()
ec := trigger.NewExecutionController()
// Wire up pool with executor
p.SetExecutor(e)
return &Manager{
config: config,
cache: c,
pool: p,
executor: e,
execController: ec,
}
}
// 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() {
lastRunInLoc := robot.LastRun.In(now.Location())
if lastRunInLoc.Format("15:04") == currentTime && lastRunInLoc.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
}
// ==================== Human Intervention & Event Triggers ====================
// Intervene processes a human intervention request
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
m.mu.RLock()
if !m.started {
m.mu.RUnlock()
return nil, fmt.Errorf("manager not started")
}
m.mu.RUnlock()
// Validate request
if err := trigger.ValidateIntervention(req); err != nil {
return nil, err
}
// Get robot from cache
robot := m.cache.Get(req.MemberID)
if robot == nil {
return nil, types.ErrRobotNotFound
}
// Check robot status
if robot.Status == types.RobotPaused {
return nil, types.ErrRobotPaused
}
// Check if human trigger is enabled
if robot.Config != nil && robot.Config.Triggers != nil {
if !robot.Config.Triggers.IsEnabled(types.TriggerHuman) {
return nil, types.ErrTriggerDisabled
}
}
// Build trigger input
triggerInput := &types.TriggerInput{
Action: req.Action,
Messages: req.Messages,
UserID: ctx.UserID(),
}
// Handle plan.add action - schedule for later
if req.Action == types.ActionPlanAdd && req.PlanTime != nil {
// TODO: Add to plan queue (Phase 11.3)
return &types.ExecutionResult{
Status: types.ExecPending,
Message: fmt.Sprintf("Planned for %s (plan queue not implemented yet)", req.PlanTime.Format(time.RFC3339)),
}, nil
}
// Submit to pool
execID, err := m.pool.Submit(ctx, robot, types.TriggerHuman, triggerInput)
if err != nil {
return nil, err
}
// Track execution for pause/resume/stop
m.execController.Track(execID, req.MemberID, req.TeamID)
return &types.ExecutionResult{
ExecutionID: execID,
Status: types.ExecPending,
Message: fmt.Sprintf("Human intervention (%s) submitted", req.Action),
}, nil
}
// HandleEvent processes an event trigger request
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
m.mu.RLock()
if !m.started {
m.mu.RUnlock()
return nil, fmt.Errorf("manager not started")
}
m.mu.RUnlock()
// Validate request
if err := trigger.ValidateEvent(req); err != nil {
return nil, err
}
// Get robot from cache
robot := m.cache.Get(req.MemberID)
if robot == nil {
return nil, types.ErrRobotNotFound
}
// Check robot status
if robot.Status == types.RobotPaused {
return nil, types.ErrRobotPaused
}
// Check if event trigger is enabled
if robot.Config != nil && robot.Config.Triggers != nil {
if !robot.Config.Triggers.IsEnabled(types.TriggerEvent) {
return nil, types.ErrTriggerDisabled
}
}
// Build trigger input
triggerInput := trigger.BuildEventInput(req)
// Submit to pool
execID, err := m.pool.Submit(ctx, robot, types.TriggerEvent, triggerInput)
if err != nil {
return nil, err
}
// Track execution for pause/resume/stop
m.execController.Track(execID, req.MemberID, "")
return &types.ExecutionResult{
ExecutionID: execID,
Status: types.ExecPending,
Message: fmt.Sprintf("Event trigger (%s: %s) submitted", req.Source, req.EventType),
}, nil
}
// ==================== Execution Control ====================
// PauseExecution pauses a running execution
func (m *Manager) PauseExecution(ctx *types.Context, execID string) error {
return m.execController.Pause(execID)
}
// ResumeExecution resumes a paused execution
func (m *Manager) ResumeExecution(ctx *types.Context, execID string) error {
return m.execController.Resume(execID)
}
// StopExecution stops a running execution
func (m *Manager) StopExecution(ctx *types.Context, execID string) error {
return m.execController.Stop(execID)
}
// GetExecutionStatus returns the status of an execution
func (m *Manager) GetExecutionStatus(execID string) (*trigger.ControlledExecution, error) {
exec := m.execController.Get(execID)
if exec == nil {
return nil, fmt.Errorf("execution not found: %s", execID)
}
return exec, nil
}
// ListExecutions returns all tracked executions
func (m *Manager) ListExecutions() []*trigger.ControlledExecution {
return m.execController.List()
}
// ListExecutionsByMember returns all executions for a specific robot
func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledExecution {
return m.execController.ListByMember(memberID)
}
// ==================== 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()
}

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,6 @@ import (
"github.com/yaoapp/yao/agent/robot/plan"
"github.com/yaoapp/yao/agent/robot/pool"
"github.com/yaoapp/yao/agent/robot/store"
"github.com/yaoapp/yao/agent/robot/trigger"
)
var (
@ -18,7 +17,6 @@ var (
globalPool *pool.Pool
globalDedup *dedup.Dedup
globalStore *store.Store
globalTrigger *trigger.Trigger
globalExecutor *executor.Executor
globalPlan *plan.Plan
)
@ -31,7 +29,6 @@ func Init() error {
globalDedup = dedup.New()
globalStore = store.New()
globalPool = pool.New() // Default pool size
globalTrigger = trigger.New()
globalExecutor = executor.New()
globalManager = manager.New()
globalPlan = plan.New()
@ -51,3 +48,8 @@ func Shutdown() error {
// }
return nil
}
// Manager returns the global manager instance
func Manager() *manager.Manager {
return globalManager
}

View file

@ -0,0 +1,126 @@
package trigger
import (
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// ClockMatcher provides clock trigger matching logic
// This is extracted from Manager for reuse and testing
type ClockMatcher struct{}
// NewClockMatcher creates a new clock matcher
func NewClockMatcher() *ClockMatcher {
return &ClockMatcher{}
}
// ShouldTrigger checks if a robot should be triggered based on its clock config
func (cm *ClockMatcher) ShouldTrigger(robot *types.Robot, now time.Time) bool {
if robot == nil || robot.Config == nil || robot.Config.Clock == nil {
return false
}
clock := robot.Config.Clock
// Get time in robot's timezone
loc := clock.GetLocation()
localNow := now.In(loc)
switch clock.Mode {
case types.ClockTimes:
return cm.shouldTriggerTimes(robot, clock, localNow)
case types.ClockInterval:
return cm.shouldTriggerInterval(robot, clock, localNow)
case types.ClockDaemon:
return cm.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 (cm *ClockMatcher) shouldTriggerTimes(robot *types.Robot, clock *types.Clock, now time.Time) bool {
// Check day of week first
if !cm.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() {
lastRunInLoc := robot.LastRun.In(now.Location())
if lastRunInLoc.Format("15:04") == currentTime && lastRunInLoc.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 (cm *ClockMatcher) 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 (cm *ClockMatcher) 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 (cm *ClockMatcher) 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
}
// ParseTime parses a time string in "HH:MM" format
func ParseTime(timeStr string) (hour, minute int, err error) {
t, err := time.Parse("15:04", timeStr)
if err != nil {
return 0, 0, err
}
return t.Hour(), t.Minute(), nil
}
// FormatTime formats hour and minute to "HH:MM" string
func FormatTime(hour, minute int) string {
return time.Date(0, 1, 1, hour, minute, 0, 0, time.UTC).Format("15:04")
}

View file

@ -0,0 +1,426 @@
package trigger_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/trigger"
"github.com/yaoapp/yao/agent/robot/types"
)
// ==================== ClockMatcher Tests ====================
func TestClockMatcherShouldTrigger(t *testing.T) {
cm := trigger.NewClockMatcher()
t.Run("nil robot returns false", func(t *testing.T) {
result := cm.ShouldTrigger(nil, time.Now())
assert.False(t, result)
})
t.Run("nil config returns false", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: nil,
}
result := cm.ShouldTrigger(robot, time.Now())
assert.False(t, result)
})
t.Run("nil clock config returns false", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{Clock: nil},
}
result := cm.ShouldTrigger(robot, time.Now())
assert.False(t, result)
})
}
// ==================== Times Mode Tests ====================
func TestClockMatcherTimesMode(t *testing.T) {
cm := trigger.NewClockMatcher()
t.Run("matches configured time", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00", "14:00", "17:00"},
Days: []string{"*"},
TZ: "UTC",
},
},
}
// Create time at 09:00 UTC
now := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
result := cm.ShouldTrigger(robot, now)
assert.True(t, result)
})
t.Run("does not match non-configured time", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00", "14:00", "17:00"},
Days: []string{"*"},
TZ: "UTC",
},
},
}
// Create time at 10:00 UTC (not in configured times)
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
result := cm.ShouldTrigger(robot, now)
assert.False(t, result)
})
t.Run("respects day filter - weekday", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00"},
Days: []string{"Mon", "Tue", "Wed", "Thu", "Fri"},
TZ: "UTC",
},
},
}
// Wednesday 09:00 - should trigger
wed := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC) // Wednesday
assert.True(t, cm.ShouldTrigger(robot, wed))
// Saturday 09:00 - should NOT trigger
sat := time.Date(2025, 1, 18, 9, 0, 0, 0, time.UTC) // Saturday
assert.False(t, cm.ShouldTrigger(robot, sat))
})
t.Run("dedup - same minute same day should not trigger twice", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00"},
Days: []string{"*"},
TZ: "UTC",
},
},
}
now := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
// First trigger - should succeed
assert.True(t, cm.ShouldTrigger(robot, now))
// Simulate LastRun was set
robot.LastRun = now
// Second trigger same minute - should fail
now2 := time.Date(2025, 1, 15, 9, 0, 30, 0, time.UTC)
assert.False(t, cm.ShouldTrigger(robot, now2))
})
t.Run("different day should trigger again", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00"},
Days: []string{"*"},
TZ: "UTC",
},
},
}
// First day
day1 := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
robot.LastRun = day1
// Next day same time - should trigger
day2 := time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC)
assert.True(t, cm.ShouldTrigger(robot, day2))
})
}
// ==================== Interval Mode Tests ====================
func TestClockMatcherIntervalMode(t *testing.T) {
cm := trigger.NewClockMatcher()
t.Run("first run triggers immediately", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockInterval,
Every: "30m",
TZ: "UTC",
},
},
}
// LastRun is zero - should trigger
now := time.Now()
result := cm.ShouldTrigger(robot, now)
assert.True(t, result)
})
t.Run("triggers after interval passed", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockInterval,
Every: "30m",
TZ: "UTC",
},
},
}
now := time.Now()
robot.LastRun = now.Add(-31 * time.Minute) // 31 minutes ago
result := cm.ShouldTrigger(robot, now)
assert.True(t, result)
})
t.Run("does not trigger before interval", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockInterval,
Every: "30m",
TZ: "UTC",
},
},
}
now := time.Now()
robot.LastRun = now.Add(-15 * time.Minute) // Only 15 minutes ago
result := cm.ShouldTrigger(robot, now)
assert.False(t, result)
})
t.Run("invalid interval format returns false", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockInterval,
Every: "invalid",
TZ: "UTC",
},
},
}
result := cm.ShouldTrigger(robot, time.Now())
assert.False(t, result)
})
t.Run("various interval formats", func(t *testing.T) {
intervals := []struct {
every string
lastAgo time.Duration
expected bool
}{
{"1h", 61 * time.Minute, true},
{"1h", 30 * time.Minute, false},
{"2h", 121 * time.Minute, true},
{"2h", 60 * time.Minute, false},
{"10s", 11 * time.Second, true},
{"10s", 5 * time.Second, false},
}
for _, tt := range intervals {
t.Run(tt.every, func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockInterval,
Every: tt.every,
TZ: "UTC",
},
},
}
now := time.Now()
robot.LastRun = now.Add(-tt.lastAgo)
result := cm.ShouldTrigger(robot, now)
assert.Equal(t, tt.expected, result)
})
}
})
}
// ==================== Daemon Mode Tests ====================
func TestClockMatcherDaemonMode(t *testing.T) {
cm := trigger.NewClockMatcher()
t.Run("triggers when robot can run", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockDaemon,
TZ: "UTC",
},
Quota: &types.Quota{Max: 2},
},
}
// No running executions - should trigger
result := cm.ShouldTrigger(robot, time.Now())
assert.True(t, result)
})
t.Run("does not trigger when at quota", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockDaemon,
TZ: "UTC",
},
Quota: &types.Quota{Max: 1},
},
}
// Add one execution to fill quota
exec := &types.Execution{ID: "exec_001"}
robot.AddExecution(exec)
result := cm.ShouldTrigger(robot, time.Now())
assert.False(t, result)
// Remove execution
robot.RemoveExecution("exec_001")
// Now should trigger
result = cm.ShouldTrigger(robot, time.Now())
assert.True(t, result)
})
}
// ==================== Timezone Tests ====================
func TestClockMatcherTimezone(t *testing.T) {
cm := trigger.NewClockMatcher()
t.Run("respects timezone for times mode", func(t *testing.T) {
// Robot configured for Asia/Shanghai (UTC+8)
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00"},
Days: []string{"*"},
TZ: "Asia/Shanghai",
},
},
}
// 01:00 UTC = 09:00 Shanghai - should trigger
utc0100 := time.Date(2025, 1, 15, 1, 0, 0, 0, time.UTC)
assert.True(t, cm.ShouldTrigger(robot, utc0100))
// 09:00 UTC = 17:00 Shanghai - should NOT trigger
utc0900 := time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC)
assert.False(t, cm.ShouldTrigger(robot, utc0900))
})
t.Run("invalid timezone falls back to local", func(t *testing.T) {
robot := &types.Robot{
MemberID: "robot_001",
Config: &types.Config{
Clock: &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00"},
Days: []string{"*"},
TZ: "Invalid/Timezone",
},
},
}
// Should still work with local time
local0900 := time.Date(2025, 1, 15, 9, 0, 0, 0, time.Local)
result := cm.ShouldTrigger(robot, local0900)
// Result depends on local timezone, just verify no panic
assert.IsType(t, true, result)
})
}
// ==================== ParseTime/FormatTime Tests ====================
func TestParseTime(t *testing.T) {
t.Run("parses valid time", func(t *testing.T) {
hour, minute, err := trigger.ParseTime("09:30")
assert.NoError(t, err)
assert.Equal(t, 9, hour)
assert.Equal(t, 30, minute)
})
t.Run("parses midnight", func(t *testing.T) {
hour, minute, err := trigger.ParseTime("00:00")
assert.NoError(t, err)
assert.Equal(t, 0, hour)
assert.Equal(t, 0, minute)
})
t.Run("parses 23:59", func(t *testing.T) {
hour, minute, err := trigger.ParseTime("23:59")
assert.NoError(t, err)
assert.Equal(t, 23, hour)
assert.Equal(t, 59, minute)
})
t.Run("invalid format returns error", func(t *testing.T) {
// Note: time.Parse("15:04", "9:30") actually succeeds
// Only truly invalid formats fail
_, _, err := trigger.ParseTime("09:30:00")
assert.Error(t, err)
_, _, err = trigger.ParseTime("invalid")
assert.Error(t, err)
_, _, err = trigger.ParseTime("")
assert.Error(t, err)
})
}
func TestFormatTime(t *testing.T) {
tests := []struct {
hour int
minute int
expected string
}{
{9, 0, "09:00"},
{9, 30, "09:30"},
{0, 0, "00:00"},
{23, 59, "23:59"},
{14, 5, "14:05"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := trigger.FormatTime(tt.hour, tt.minute)
assert.Equal(t, tt.expected, result)
})
}
}

View file

@ -0,0 +1,248 @@
package trigger
import (
"context"
"fmt"
"sync"
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// ExecutionController manages execution lifecycle (pause/resume/stop)
type ExecutionController struct {
executions map[string]*ControlledExecution
mu sync.RWMutex
}
// ControlledExecution represents an execution that can be controlled
type ControlledExecution struct {
ID string
MemberID string
TeamID string
Status types.ExecStatus
Phase types.Phase
StartTime time.Time
PausedAt *time.Time
// Control channels
ctx context.Context
cancel context.CancelFunc
paused bool
pauseMu sync.Mutex
resumeCh chan struct{} // signaled (closed) when resumed
}
// NewExecutionController creates a new execution controller
func NewExecutionController() *ExecutionController {
return &ExecutionController{
executions: make(map[string]*ControlledExecution),
}
}
// Track starts tracking an execution
func (c *ExecutionController) Track(execID, memberID, teamID string) *ControlledExecution {
c.mu.Lock()
defer c.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
exec := &ControlledExecution{
ID: execID,
MemberID: memberID,
TeamID: teamID,
Status: types.ExecRunning,
Phase: types.PhaseInspiration,
StartTime: time.Now(),
ctx: ctx,
cancel: cancel,
paused: false,
resumeCh: nil, // nil when not paused, created on pause
}
c.executions[execID] = exec
return exec
}
// Untrack stops tracking an execution
func (c *ExecutionController) Untrack(execID string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.executions, execID)
}
// Get returns a tracked execution
func (c *ExecutionController) Get(execID string) *ControlledExecution {
c.mu.RLock()
defer c.mu.RUnlock()
return c.executions[execID]
}
// List returns all tracked executions
func (c *ExecutionController) List() []*ControlledExecution {
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]*ControlledExecution, 0, len(c.executions))
for _, exec := range c.executions {
result = append(result, exec)
}
return result
}
// ListByMember returns all executions for a specific member
func (c *ExecutionController) ListByMember(memberID string) []*ControlledExecution {
c.mu.RLock()
defer c.mu.RUnlock()
var result []*ControlledExecution
for _, exec := range c.executions {
if exec.MemberID == memberID {
result = append(result, exec)
}
}
return result
}
// Pause pauses an execution
func (c *ExecutionController) Pause(execID string) error {
exec := c.Get(execID)
if exec == nil {
return fmt.Errorf("execution not found: %s", execID)
}
exec.pauseMu.Lock()
defer exec.pauseMu.Unlock()
if exec.paused {
return fmt.Errorf("execution already paused: %s", execID)
}
exec.paused = true
now := time.Now()
exec.PausedAt = &now
// Create a new resume channel that will be closed on resume
exec.resumeCh = make(chan struct{})
return nil
}
// Resume resumes a paused execution
func (c *ExecutionController) Resume(execID string) error {
exec := c.Get(execID)
if exec == nil {
return fmt.Errorf("execution not found: %s", execID)
}
exec.pauseMu.Lock()
defer exec.pauseMu.Unlock()
if !exec.paused {
return fmt.Errorf("execution not paused: %s", execID)
}
exec.paused = false
exec.PausedAt = nil
// Close the resume channel to signal resume to waiting goroutines
if exec.resumeCh != nil {
close(exec.resumeCh)
exec.resumeCh = nil
}
return nil
}
// Stop stops an execution
func (c *ExecutionController) Stop(execID string) error {
c.mu.Lock()
defer c.mu.Unlock()
exec, ok := c.executions[execID]
if !ok {
return fmt.Errorf("execution not found: %s", execID)
}
// Cancel the context to signal stop
if exec.cancel != nil {
exec.cancel()
}
exec.Status = types.ExecCancelled
// Remove from tracking
delete(c.executions, execID)
return nil
}
// ==================== ControlledExecution methods ====================
// IsPaused returns true if the execution is paused
func (e *ControlledExecution) IsPaused() bool {
e.pauseMu.Lock()
defer e.pauseMu.Unlock()
return e.paused
}
// IsCancelled returns true if the execution is cancelled
func (e *ControlledExecution) IsCancelled() bool {
select {
case <-e.ctx.Done():
return true
default:
return false
}
}
// Context returns the execution's context
func (e *ControlledExecution) Context() context.Context {
return e.ctx
}
// WaitIfPaused blocks until the execution is resumed or cancelled
// Returns error if cancelled
func (e *ControlledExecution) WaitIfPaused() error {
e.pauseMu.Lock()
paused := e.paused
resumeCh := e.resumeCh
e.pauseMu.Unlock()
if !paused {
return nil
}
// Safety check: if paused but resumeCh is nil (shouldn't happen in normal flow),
// treat as not paused to avoid blocking forever on nil channel
if resumeCh == nil {
return nil
}
// resumeCh is created when paused and closed when resumed
// Wait for resume signal or cancellation
select {
case <-e.ctx.Done():
return types.ErrExecutionCancelled
case <-resumeCh:
// Resume signal received, execution can continue
return nil
}
}
// CheckCancelled checks if the execution is cancelled and returns error if so
func (e *ControlledExecution) CheckCancelled() error {
if e.IsCancelled() {
return types.ErrExecutionCancelled
}
return nil
}
// UpdatePhase updates the current phase
func (e *ControlledExecution) UpdatePhase(phase types.Phase) {
e.Phase = phase
}
// UpdateStatus updates the execution status
func (e *ControlledExecution) UpdateStatus(status types.ExecStatus) {
e.Status = status
}

View file

@ -0,0 +1,464 @@
package trigger_test
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/trigger"
"github.com/yaoapp/yao/agent/robot/types"
)
// ==================== ExecutionController Tests ====================
func TestExecutionControllerTrack(t *testing.T) {
t.Run("tracks new execution", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
assert.NotNil(t, exec)
assert.Equal(t, "exec_001", exec.ID)
assert.Equal(t, "robot_001", exec.MemberID)
assert.Equal(t, "team_001", exec.TeamID)
assert.Equal(t, types.ExecRunning, exec.Status)
assert.False(t, exec.IsPaused())
assert.False(t, exec.IsCancelled())
})
t.Run("get tracked execution", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
ctrl.Track("exec_001", "robot_001", "team_001")
exec := ctrl.Get("exec_001")
assert.NotNil(t, exec)
assert.Equal(t, "exec_001", exec.ID)
})
t.Run("get non-existent execution returns nil", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Get("non_existent")
assert.Nil(t, exec)
})
}
func TestExecutionControllerList(t *testing.T) {
t.Run("list all executions", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
ctrl.Track("exec_001", "robot_001", "team_001")
ctrl.Track("exec_002", "robot_002", "team_001")
ctrl.Track("exec_003", "robot_001", "team_002")
list := ctrl.List()
assert.Len(t, list, 3)
})
t.Run("list by member", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
ctrl.Track("exec_001", "robot_001", "team_001")
ctrl.Track("exec_002", "robot_002", "team_001")
ctrl.Track("exec_003", "robot_001", "team_002")
list := ctrl.ListByMember("robot_001")
assert.Len(t, list, 2)
list = ctrl.ListByMember("robot_002")
assert.Len(t, list, 1)
list = ctrl.ListByMember("robot_003")
assert.Len(t, list, 0)
})
}
func TestExecutionControllerUntrack(t *testing.T) {
t.Run("untrack removes execution", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
ctrl.Track("exec_001", "robot_001", "team_001")
assert.NotNil(t, ctrl.Get("exec_001"))
ctrl.Untrack("exec_001")
assert.Nil(t, ctrl.Get("exec_001"))
})
t.Run("untrack non-existent does not panic", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
assert.NotPanics(t, func() {
ctrl.Untrack("non_existent")
})
})
}
// ==================== Pause/Resume Tests ====================
func TestExecutionControllerPause(t *testing.T) {
t.Run("pause execution", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
err := ctrl.Pause("exec_001")
assert.NoError(t, err)
assert.True(t, exec.IsPaused())
assert.NotNil(t, exec.PausedAt)
})
t.Run("pause non-existent returns error", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
err := ctrl.Pause("non_existent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
t.Run("pause already paused returns error", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
ctrl.Track("exec_001", "robot_001", "team_001")
err := ctrl.Pause("exec_001")
assert.NoError(t, err)
err = ctrl.Pause("exec_001")
assert.Error(t, err)
assert.Contains(t, err.Error(), "already paused")
})
}
func TestExecutionControllerResume(t *testing.T) {
t.Run("resume paused execution", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
ctrl.Pause("exec_001")
assert.True(t, exec.IsPaused())
err := ctrl.Resume("exec_001")
assert.NoError(t, err)
assert.False(t, exec.IsPaused())
assert.Nil(t, exec.PausedAt)
})
t.Run("resume non-existent returns error", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
err := ctrl.Resume("non_existent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
t.Run("resume not paused returns error", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
ctrl.Track("exec_001", "robot_001", "team_001")
err := ctrl.Resume("exec_001")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not paused")
})
}
// ==================== Stop Tests ====================
func TestExecutionControllerStop(t *testing.T) {
t.Run("stop execution", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
err := ctrl.Stop("exec_001")
assert.NoError(t, err)
assert.True(t, exec.IsCancelled())
assert.Equal(t, types.ExecCancelled, exec.Status)
// Should be removed from tracking
assert.Nil(t, ctrl.Get("exec_001"))
})
t.Run("stop non-existent returns error", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
err := ctrl.Stop("non_existent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
}
// ==================== ControlledExecution Methods Tests ====================
func TestControlledExecutionContext(t *testing.T) {
t.Run("context is valid", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
ctx := exec.Context()
assert.NotNil(t, ctx)
// Context should not be done yet
select {
case <-ctx.Done():
t.Fatal("context should not be done")
default:
// OK
}
})
t.Run("context done after stop", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
ctx := exec.Context()
ctrl.Stop("exec_001")
select {
case <-ctx.Done():
// OK
case <-time.After(100 * time.Millisecond):
t.Fatal("context should be done after stop")
}
})
}
func TestControlledExecutionCheckCancelled(t *testing.T) {
t.Run("not cancelled returns nil", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
err := exec.CheckCancelled()
assert.NoError(t, err)
})
t.Run("cancelled returns error", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
ctrl.Stop("exec_001")
err := exec.CheckCancelled()
assert.Error(t, err)
assert.Equal(t, types.ErrExecutionCancelled, err)
})
}
func TestControlledExecutionUpdatePhase(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
assert.Equal(t, types.PhaseInspiration, exec.Phase)
exec.UpdatePhase(types.PhaseGoals)
assert.Equal(t, types.PhaseGoals, exec.Phase)
exec.UpdatePhase(types.PhaseTasks)
assert.Equal(t, types.PhaseTasks, exec.Phase)
}
func TestControlledExecutionUpdateStatus(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
assert.Equal(t, types.ExecRunning, exec.Status)
exec.UpdateStatus(types.ExecCompleted)
assert.Equal(t, types.ExecCompleted, exec.Status)
exec.UpdateStatus(types.ExecFailed)
assert.Equal(t, types.ExecFailed, exec.Status)
}
// ==================== WaitIfPaused Tests ====================
func TestControlledExecutionWaitIfPaused(t *testing.T) {
t.Run("returns immediately if not paused", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
done := make(chan error)
go func() {
done <- exec.WaitIfPaused()
}()
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(100 * time.Millisecond):
t.Fatal("WaitIfPaused should return immediately when not paused")
}
})
t.Run("does not infinite loop when paused without resume", func(t *testing.T) {
// This test verifies the fix for the infinite loop bug
// where WaitIfPaused would spin if pauseCh was closed but paused remained true
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
ctrl.Pause("exec_001")
// Start WaitIfPaused in a goroutine
done := make(chan error)
go func() {
done <- exec.WaitIfPaused()
}()
// Wait a bit - if there's an infinite loop, CPU would spike
// The goroutine should be blocked, not spinning
time.Sleep(100 * time.Millisecond)
// Now stop the execution - this should unblock WaitIfPaused
ctrl.Stop("exec_001")
select {
case err := <-done:
// Should get cancellation error
assert.Error(t, err)
case <-time.After(200 * time.Millisecond):
t.Fatal("WaitIfPaused should unblock after stop")
}
})
t.Run("rapid pause-resume-pause does not cause issues", func(t *testing.T) {
// Test TOCTOU race condition handling
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
// Pause first
ctrl.Pause("exec_001")
done := make(chan error)
go func() {
done <- exec.WaitIfPaused()
}()
// Rapid resume then pause again
time.Sleep(10 * time.Millisecond)
ctrl.Resume("exec_001")
// WaitIfPaused should return (the original resumeCh was closed)
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(200 * time.Millisecond):
t.Fatal("WaitIfPaused should return after resume")
}
})
t.Run("blocks when paused, resumes after resume", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
ctrl.Pause("exec_001")
done := make(chan error)
go func() {
done <- exec.WaitIfPaused()
}()
// Should be blocked
select {
case <-done:
t.Fatal("WaitIfPaused should block when paused")
case <-time.After(50 * time.Millisecond):
// OK, still blocked
}
// Resume
ctrl.Resume("exec_001")
// Should unblock
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(100 * time.Millisecond):
t.Fatal("WaitIfPaused should unblock after resume")
}
})
t.Run("returns error when cancelled while paused", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
exec := ctrl.Track("exec_001", "robot_001", "team_001")
ctrl.Pause("exec_001")
done := make(chan error)
go func() {
done <- exec.WaitIfPaused()
}()
// Should be blocked
select {
case <-done:
t.Fatal("WaitIfPaused should block when paused")
case <-time.After(50 * time.Millisecond):
// OK, still blocked
}
// Stop instead of resume
ctrl.Stop("exec_001")
// Should unblock with error
select {
case err := <-done:
assert.Error(t, err)
assert.Equal(t, types.ErrExecutionCancelled, err)
case <-time.After(100 * time.Millisecond):
t.Fatal("WaitIfPaused should unblock after stop")
}
})
}
// ==================== Concurrent Access Tests ====================
func TestExecutionControllerConcurrency(t *testing.T) {
t.Run("concurrent track and list", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
var wg sync.WaitGroup
// Concurrent tracking
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ctrl.Track(
"exec_"+string(rune('0'+id%10)),
"robot_"+string(rune('0'+id%5)),
"team_001",
)
}(i)
}
// Concurrent listing
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = ctrl.List()
}()
}
wg.Wait()
// No race conditions or panics
})
t.Run("concurrent pause/resume", func(t *testing.T) {
ctrl := trigger.NewExecutionController()
ctrl.Track("exec_001", "robot_001", "team_001")
var wg sync.WaitGroup
// Concurrent pause/resume attempts
for i := 0; i < 50; i++ {
wg.Add(2)
go func() {
defer wg.Done()
_ = ctrl.Pause("exec_001")
}()
go func() {
defer wg.Done()
_ = ctrl.Resume("exec_001")
}()
}
wg.Wait()
// No race conditions or panics
})
}

View file

@ -1,48 +1,142 @@
// Package trigger provides trigger-related utilities and execution control
// The main trigger logic is in the manager package.
// This package provides:
// - Validation functions for intervention and event requests
// - Builder helpers for TriggerInput
// - ExecutionController for pause/resume/stop
// - ClockMatcher for clock trigger matching (reusable)
package trigger
import "github.com/yaoapp/yao/agent/robot/types"
import (
"fmt"
// Trigger handles all trigger sources
// This is a stub implementation for Phase 2
type Trigger struct{}
"github.com/yaoapp/yao/agent/robot/types"
)
// New creates a new trigger instance
func New() *Trigger {
return &Trigger{}
}
// ValidateIntervention validates a human intervention request
func ValidateIntervention(req *types.InterveneRequest) error {
if req == nil {
return fmt.Errorf("request is nil")
}
if req.MemberID == "" {
return fmt.Errorf("member_id is required")
}
if !isValidAction(req.Action) {
return fmt.Errorf("invalid action: %s", req.Action)
}
// Validate action-specific requirements
switch req.Action {
case types.ActionTaskAdd, types.ActionGoalAdd, types.ActionInstruct:
// These actions require messages
if len(req.Messages) == 0 {
return fmt.Errorf("messages required for action: %s", req.Action)
}
case types.ActionPlanAdd:
// Plan add requires plan_time
if req.PlanTime == nil {
return fmt.Errorf("plan_time required for action: plan.add")
}
}
// Clock processes clock trigger
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Clock(ctx *types.Context, robot *types.Robot) error {
return nil
}
// Intervene processes human intervention
// Stub: returns empty result (will be implemented in Phase 3)
func (t *Trigger) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
return &types.ExecutionResult{}, nil
}
// ValidateEvent validates an event trigger request
func ValidateEvent(req *types.EventRequest) error {
if req == nil {
return fmt.Errorf("request is nil")
}
// Event processes event trigger
// Stub: returns empty result (will be implemented in Phase 3)
func (t *Trigger) Event(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
return &types.ExecutionResult{}, nil
}
if req.MemberID == "" {
return fmt.Errorf("member_id is required")
}
if req.Source == "" {
return fmt.Errorf("source is required")
}
if req.EventType == "" {
return fmt.Errorf("event_type is required")
}
// Pause pauses a running execution
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Pause(ctx *types.Context, execID string) error {
return nil
}
// Resume resumes a paused execution
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Resume(ctx *types.Context, execID string) error {
return nil
// BuildEventInput creates a TriggerInput from an event request
func BuildEventInput(req *types.EventRequest) *types.TriggerInput {
return &types.TriggerInput{
Source: types.EventSource(req.Source),
EventType: req.EventType,
Data: req.Data,
}
}
// Stop stops a running execution
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Stop(ctx *types.Context, execID string) error {
return nil
// isValidAction checks if the intervention action is valid
func isValidAction(action types.InterventionAction) bool {
switch action {
case types.ActionTaskAdd,
types.ActionTaskCancel,
types.ActionTaskUpdate,
types.ActionGoalAdjust,
types.ActionGoalAdd,
types.ActionGoalComplete,
types.ActionGoalCancel,
types.ActionPlanAdd,
types.ActionPlanRemove,
types.ActionPlanUpdate,
types.ActionInstruct:
return true
default:
return false
}
}
// GetActionCategory returns the category of an intervention action
func GetActionCategory(action types.InterventionAction) string {
switch action {
case types.ActionTaskAdd, types.ActionTaskCancel, types.ActionTaskUpdate:
return "task"
case types.ActionGoalAdjust, types.ActionGoalAdd, types.ActionGoalComplete, types.ActionGoalCancel:
return "goal"
case types.ActionPlanAdd, types.ActionPlanRemove, types.ActionPlanUpdate:
return "plan"
case types.ActionInstruct:
return "instruct"
default:
return "unknown"
}
}
// GetActionDescription returns a human-readable description of an action
func GetActionDescription(action types.InterventionAction) string {
switch action {
case types.ActionTaskAdd:
return "Add a new task"
case types.ActionTaskCancel:
return "Cancel a task"
case types.ActionTaskUpdate:
return "Update task details"
case types.ActionGoalAdjust:
return "Adjust current goal"
case types.ActionGoalAdd:
return "Add a new goal"
case types.ActionGoalComplete:
return "Mark goal as complete"
case types.ActionGoalCancel:
return "Cancel a goal"
case types.ActionPlanAdd:
return "Add to plan queue"
case types.ActionPlanRemove:
return "Remove from plan queue"
case types.ActionPlanUpdate:
return "Update planned item"
case types.ActionInstruct:
return "Direct instruction to robot"
default:
return "Unknown action"
}
}

View file

@ -0,0 +1,276 @@
package trigger_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/robot/trigger"
"github.com/yaoapp/yao/agent/robot/types"
)
// ==================== ValidateIntervention Tests ====================
func TestValidateIntervention(t *testing.T) {
t.Run("nil request returns error", func(t *testing.T) {
err := trigger.ValidateIntervention(nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "request is nil")
})
t.Run("empty member_id returns error", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "",
Action: types.ActionTaskAdd,
}
err := trigger.ValidateIntervention(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "member_id is required")
})
t.Run("invalid action returns error", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.InterventionAction("invalid.action"),
}
err := trigger.ValidateIntervention(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid action")
})
t.Run("task.add without messages returns error", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionTaskAdd,
Messages: nil,
}
err := trigger.ValidateIntervention(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "messages required")
})
t.Run("goal.add without messages returns error", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionGoalAdd,
Messages: nil,
}
err := trigger.ValidateIntervention(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "messages required")
})
t.Run("instruct without messages returns error", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionInstruct,
Messages: nil,
}
err := trigger.ValidateIntervention(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "messages required")
})
t.Run("plan.add without plan_time returns error", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionPlanAdd,
PlanTime: nil,
}
err := trigger.ValidateIntervention(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "plan_time required")
})
t.Run("valid task.add request passes", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionTaskAdd,
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Add a new task"},
},
}
err := trigger.ValidateIntervention(req)
assert.NoError(t, err)
})
t.Run("valid plan.add request passes", func(t *testing.T) {
planTime := time.Now().Add(time.Hour)
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionPlanAdd,
PlanTime: &planTime,
}
err := trigger.ValidateIntervention(req)
assert.NoError(t, err)
})
t.Run("task.cancel without messages passes", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionTaskCancel,
}
err := trigger.ValidateIntervention(req)
assert.NoError(t, err)
})
t.Run("goal.adjust without messages passes", func(t *testing.T) {
req := &types.InterveneRequest{
MemberID: "robot_001",
Action: types.ActionGoalAdjust,
}
err := trigger.ValidateIntervention(req)
assert.NoError(t, err)
})
}
// ==================== ValidateEvent Tests ====================
func TestValidateEvent(t *testing.T) {
t.Run("nil request returns error", func(t *testing.T) {
err := trigger.ValidateEvent(nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "request is nil")
})
t.Run("empty member_id returns error", func(t *testing.T) {
req := &types.EventRequest{
MemberID: "",
Source: "webhook",
EventType: "lead.created",
}
err := trigger.ValidateEvent(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "member_id is required")
})
t.Run("empty source returns error", func(t *testing.T) {
req := &types.EventRequest{
MemberID: "robot_001",
Source: "",
EventType: "lead.created",
}
err := trigger.ValidateEvent(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "source is required")
})
t.Run("empty event_type returns error", func(t *testing.T) {
req := &types.EventRequest{
MemberID: "robot_001",
Source: "webhook",
EventType: "",
}
err := trigger.ValidateEvent(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "event_type is required")
})
t.Run("valid request passes", func(t *testing.T) {
req := &types.EventRequest{
MemberID: "robot_001",
Source: "webhook",
EventType: "lead.created",
Data: map[string]interface{}{"name": "John"},
}
err := trigger.ValidateEvent(req)
assert.NoError(t, err)
})
}
// ==================== BuildEventInput Tests ====================
func TestBuildEventInput(t *testing.T) {
t.Run("builds correct TriggerInput", func(t *testing.T) {
req := &types.EventRequest{
MemberID: "robot_001",
Source: "webhook",
EventType: "lead.created",
Data: map[string]interface{}{"name": "John", "email": "john@example.com"},
}
input := trigger.BuildEventInput(req)
assert.NotNil(t, input)
assert.Equal(t, types.EventSource("webhook"), input.Source)
assert.Equal(t, "lead.created", input.EventType)
assert.Equal(t, "John", input.Data["name"])
assert.Equal(t, "john@example.com", input.Data["email"])
})
t.Run("handles nil data", func(t *testing.T) {
req := &types.EventRequest{
MemberID: "robot_001",
Source: "database",
EventType: "order.paid",
Data: nil,
}
input := trigger.BuildEventInput(req)
assert.NotNil(t, input)
assert.Equal(t, types.EventSource("database"), input.Source)
assert.Equal(t, "order.paid", input.EventType)
assert.Nil(t, input.Data)
})
}
// ==================== GetActionCategory Tests ====================
func TestGetActionCategory(t *testing.T) {
tests := []struct {
action types.InterventionAction
expected string
}{
{types.ActionTaskAdd, "task"},
{types.ActionTaskCancel, "task"},
{types.ActionTaskUpdate, "task"},
{types.ActionGoalAdjust, "goal"},
{types.ActionGoalAdd, "goal"},
{types.ActionGoalComplete, "goal"},
{types.ActionGoalCancel, "goal"},
{types.ActionPlanAdd, "plan"},
{types.ActionPlanRemove, "plan"},
{types.ActionPlanUpdate, "plan"},
{types.ActionInstruct, "instruct"},
{types.InterventionAction("unknown"), "unknown"},
}
for _, tt := range tests {
t.Run(string(tt.action), func(t *testing.T) {
result := trigger.GetActionCategory(tt.action)
assert.Equal(t, tt.expected, result)
})
}
}
// ==================== GetActionDescription Tests ====================
func TestGetActionDescription(t *testing.T) {
tests := []struct {
action types.InterventionAction
contains string
}{
{types.ActionTaskAdd, "Add"},
{types.ActionTaskCancel, "Cancel"},
{types.ActionTaskUpdate, "Update"},
{types.ActionGoalAdjust, "Adjust"},
{types.ActionGoalAdd, "Add"},
{types.ActionGoalComplete, "complete"},
{types.ActionGoalCancel, "Cancel"},
{types.ActionPlanAdd, "plan"},
{types.ActionPlanRemove, "Remove"},
{types.ActionPlanUpdate, "Update"},
{types.ActionInstruct, "instruction"},
{types.InterventionAction("unknown"), "Unknown"},
}
for _, tt := range tests {
t.Run(string(tt.action), func(t *testing.T) {
result := trigger.GetActionDescription(tt.action)
assert.NotEmpty(t, result)
assert.Contains(t, result, tt.contains)
})
}
}