feat: add cron tool integration with agent

- Add adhocore/gronx dependency for cron expression parsing
- Fix CronService race conditions and add cron expression support
- Add CronTool with add/list/remove/enable/disable actions
- Add ContextualTool interface for tools needing channel/chatID context
- Add ProcessDirectWithChannel to AgentLoop for cron job execution
- Register CronTool in gateway and wire up onJob handler
- Fix slice bounds panic in addJob for short messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yinwm
2026-02-11 12:28:37 +08:00
parent 3e902abb5c
commit 6d4d2bc61e
8 changed files with 401 additions and 37 deletions

View File

@@ -1,12 +1,17 @@
package cron
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/adhocore/gronx"
)
type CronSchedule struct {
@@ -58,6 +63,7 @@ type CronService struct {
mu sync.RWMutex
running bool
stopChan chan struct{}
gronx *gronx.Gronx
}
func NewCronService(storePath string, onJob JobHandler) *CronService {
@@ -65,7 +71,9 @@ func NewCronService(storePath string, onJob JobHandler) *CronService {
storePath: storePath,
onJob: onJob,
stopChan: make(chan struct{}),
gronx: gronx.New(),
}
// Initialize and load store on creation
cs.loadStore()
return cs
}
@@ -83,7 +91,7 @@ func (cs *CronService) Start() error {
}
cs.recomputeNextRuns()
if err := cs.saveStore(); err != nil {
if err := cs.saveStoreUnsafe(); err != nil {
return fmt.Errorf("failed to save store: %w", err)
}
@@ -120,30 +128,47 @@ func (cs *CronService) runLoop() {
}
func (cs *CronService) checkJobs() {
cs.mu.RLock()
cs.mu.Lock()
if !cs.running {
cs.mu.RUnlock()
cs.mu.Unlock()
return
}
now := time.Now().UnixMilli()
var dueJobs []*CronJob
// Collect jobs that are due (we need to copy them to execute outside lock)
for i := range cs.store.Jobs {
job := &cs.store.Jobs[i]
if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now {
dueJobs = append(dueJobs, job)
// Create a shallow copy of the job for execution
jobCopy := *job
dueJobs = append(dueJobs, &jobCopy)
}
}
cs.mu.RUnlock()
// Update next run times for due jobs immediately (before executing)
for i := range cs.store.Jobs {
for _, dueJob := range dueJobs {
if cs.store.Jobs[i].ID == dueJob.ID {
// Reset NextRunAtMS temporarily so we don't re-execute
cs.store.Jobs[i].State.NextRunAtMS = nil
break
}
}
}
if err := cs.saveStoreUnsafe(); err != nil {
log.Printf("[cron] failed to save store: %v", err)
}
cs.mu.Unlock()
// Execute jobs outside the lock
for _, job := range dueJobs {
cs.executeJob(job)
}
cs.mu.Lock()
defer cs.mu.Unlock()
cs.saveStore()
}
func (cs *CronService) executeJob(job *CronJob) {
@@ -154,30 +179,42 @@ func (cs *CronService) executeJob(job *CronJob) {
_, err = cs.onJob(job)
}
// Now acquire lock to update state
cs.mu.Lock()
defer cs.mu.Unlock()
job.State.LastRunAtMS = &startTime
job.UpdatedAtMS = time.Now().UnixMilli()
// Find the job in store and update it
for i := range cs.store.Jobs {
if cs.store.Jobs[i].ID == job.ID {
cs.store.Jobs[i].State.LastRunAtMS = &startTime
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
if err != nil {
job.State.LastStatus = "error"
job.State.LastError = err.Error()
} else {
job.State.LastStatus = "ok"
job.State.LastError = ""
if err != nil {
cs.store.Jobs[i].State.LastStatus = "error"
cs.store.Jobs[i].State.LastError = err.Error()
} else {
cs.store.Jobs[i].State.LastStatus = "ok"
cs.store.Jobs[i].State.LastError = ""
}
// Compute next run time
if cs.store.Jobs[i].Schedule.Kind == "at" {
if cs.store.Jobs[i].DeleteAfterRun {
cs.removeJobUnsafe(job.ID)
} else {
cs.store.Jobs[i].Enabled = false
cs.store.Jobs[i].State.NextRunAtMS = nil
}
} else {
nextRun := cs.computeNextRun(&cs.store.Jobs[i].Schedule, time.Now().UnixMilli())
cs.store.Jobs[i].State.NextRunAtMS = nextRun
}
break
}
}
if job.Schedule.Kind == "at" {
if job.DeleteAfterRun {
cs.removeJobUnsafe(job.ID)
} else {
job.Enabled = false
job.State.NextRunAtMS = nil
}
} else {
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
job.State.NextRunAtMS = nextRun
if err := cs.saveStoreUnsafe(); err != nil {
log.Printf("[cron] failed to save store: %v", err)
}
}
@@ -197,6 +234,23 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6
return &next
}
if schedule.Kind == "cron" {
if schedule.Expr == "" {
return nil
}
// Use gronx to calculate next run time
now := time.UnixMilli(nowMS)
nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false)
if err != nil {
log.Printf("[cron] failed to compute next run for expr '%s': %v", schedule.Expr, err)
return nil
}
nextMS := nextTime.UnixMilli()
return &nextMS
}
return nil
}
@@ -223,9 +277,17 @@ func (cs *CronService) getNextWakeMS() *int64 {
}
func (cs *CronService) Load() error {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.loadStore()
}
func (cs *CronService) SetOnJob(handler JobHandler) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.onJob = handler
}
func (cs *CronService) loadStore() error {
cs.store = &CronStore{
Version: 1,
@@ -243,7 +305,7 @@ func (cs *CronService) loadStore() error {
return json.Unmarshal(data, cs.store)
}
func (cs *CronService) saveStore() error {
func (cs *CronService) saveStoreUnsafe() error {
dir := filepath.Dir(cs.storePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
@@ -284,7 +346,7 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string
}
cs.store.Jobs = append(cs.store.Jobs, job)
if err := cs.saveStore(); err != nil {
if err := cs.saveStoreUnsafe(); err != nil {
return nil, err
}
@@ -310,7 +372,9 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool {
removed := len(cs.store.Jobs) < before
if removed {
cs.saveStore()
if err := cs.saveStoreUnsafe(); err != nil {
log.Printf("[cron] failed to save store after remove: %v", err)
}
}
return removed
@@ -332,7 +396,9 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
job.State.NextRunAtMS = nil
}
cs.saveStore()
if err := cs.saveStoreUnsafe(); err != nil {
log.Printf("[cron] failed to save store after enable: %v", err)
}
return job
}
}
@@ -377,5 +443,11 @@ func (cs *CronService) Status() map[string]interface{} {
}
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
// Use crypto/rand for better uniqueness under concurrent access
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// Fallback to time-based if crypto/rand fails
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}