Changeset 0.27.2 (#169)
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
// every 30 seconds, creates service channels, and dispatches execution.
|
||||
//
|
||||
// v0.27.1: Foundation — scheduler loop + service channel creation.
|
||||
// v0.27.2: Adds completion invocation, budget enforcement, and wall-clock timeout.
|
||||
// v0.27.2: Adds global config checks (enabled, max_concurrent), full cron
|
||||
// parsing via robfig/cron/v3, and completion invocation via executor.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
@@ -12,20 +13,25 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
)
|
||||
|
||||
// Scheduler polls for due tasks and dispatches execution.
|
||||
type Scheduler struct {
|
||||
stores store.Stores
|
||||
executor *Executor
|
||||
interval time.Duration
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// New creates a task scheduler. Call Run() in a goroutine to start.
|
||||
func New(stores store.Stores) *Scheduler {
|
||||
// The executor is optional — if nil, tasks create channels and persist
|
||||
// prompts but do not invoke completions (v0.27.1 behavior).
|
||||
func New(stores store.Stores, executor *Executor) *Scheduler {
|
||||
return &Scheduler{
|
||||
stores: stores,
|
||||
executor: executor,
|
||||
interval: 30 * time.Second,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
@@ -67,7 +73,13 @@ func (s *Scheduler) poll() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
due, err := s.stores.Tasks.ListDue(ctx, 10)
|
||||
// v0.27.2: Check global config — tasks may be disabled at runtime.
|
||||
cfg := taskutil.LoadTaskConfig(ctx, s.stores.GlobalConfig)
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
due, err := s.stores.Tasks.ListDue(ctx, cfg.MaxConcurrent)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] Failed to list due tasks: %v", err)
|
||||
return
|
||||
@@ -123,17 +135,16 @@ func (s *Scheduler) execute(parentCtx context.Context, task models.Task) {
|
||||
})
|
||||
}
|
||||
|
||||
// v0.27.2 TODO: Invoke completion pipeline with:
|
||||
// - task.SystemPrompt prepended to persona system prompt
|
||||
// - task.ModelID or provider resolution
|
||||
// - Budget enforcement (max_tokens, max_tool_calls, max_wall_clock)
|
||||
// - Tool grant filtering from task.ToolGrants
|
||||
//
|
||||
// For now, mark the run as completed (channel created + prompt persisted).
|
||||
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "")
|
||||
_ = s.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||
// v0.27.2: Invoke completion via executor
|
||||
if s.executor != nil && task.TaskType == "prompt" {
|
||||
s.executor.Execute(ctx, task, run, channelID)
|
||||
} else {
|
||||
// No executor or non-prompt task type — mark completed (channel + prompt persisted)
|
||||
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "")
|
||||
_ = s.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||
}
|
||||
|
||||
log.Printf("[scheduler] Task %s completed (channel %s)", task.ID, channelID)
|
||||
log.Printf("[scheduler] Task %s finished (channel %s)", task.ID, channelID)
|
||||
|
||||
s.advanceNextRun(ctx, task)
|
||||
}
|
||||
@@ -176,108 +187,14 @@ func (s *Scheduler) advanceNextRun(ctx context.Context, task models.Task) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cron schedule — compute next run.
|
||||
// v0.27.1: Uses a simple interval-based fallback.
|
||||
// v0.27.2: Full cron parsing with robfig/cron/v3.
|
||||
next := computeNextRun(task.Schedule, task.Timezone)
|
||||
// v0.27.2: Full cron parsing via robfig/cron/v3 (replaces hand-rolled parser).
|
||||
next := taskutil.NextRunFromSchedule(task.Schedule, task.Timezone)
|
||||
if next == nil {
|
||||
log.Printf("[scheduler] Failed to compute next run for task %s (schedule: %q) — deactivating", task.ID, task.Schedule)
|
||||
isActive := false
|
||||
_ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{IsActive: &isActive})
|
||||
_ = s.stores.Tasks.SetNextRun(ctx, task.ID, nil)
|
||||
return
|
||||
}
|
||||
_ = s.stores.Tasks.SetNextRun(ctx, task.ID, next)
|
||||
}
|
||||
|
||||
// computeNextRun parses a cron expression and returns the next execution time.
|
||||
// Supports common presets and basic 5-field cron.
|
||||
func computeNextRun(schedule, timezone string) *time.Time {
|
||||
now := time.Now()
|
||||
|
||||
// Load timezone
|
||||
if tz, err := time.LoadLocation(timezone); err == nil {
|
||||
now = now.In(tz)
|
||||
}
|
||||
|
||||
// Preset schedules (common cases without a full cron parser)
|
||||
var next time.Time
|
||||
switch schedule {
|
||||
case "once":
|
||||
return nil // Already handled
|
||||
case "0 * * * *": // Every hour
|
||||
next = now.Truncate(time.Hour).Add(time.Hour)
|
||||
case "*/5 * * * *": // Every 5 minutes
|
||||
next = now.Truncate(5 * time.Minute).Add(5 * time.Minute)
|
||||
case "*/15 * * * *": // Every 15 minutes
|
||||
next = now.Truncate(15 * time.Minute).Add(15 * time.Minute)
|
||||
case "*/30 * * * *": // Every 30 minutes
|
||||
next = now.Truncate(30 * time.Minute).Add(30 * time.Minute)
|
||||
default:
|
||||
// Fallback: try to parse minute and hour fields for daily/weekly cron
|
||||
next = parseDailyCron(schedule, now)
|
||||
}
|
||||
|
||||
utc := next.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
// parseDailyCron handles "M H * * *" and "M H * * D" patterns.
|
||||
// Returns now+1h as fallback for unparseable expressions.
|
||||
func parseDailyCron(expr string, now time.Time) time.Time {
|
||||
// Split into fields
|
||||
var fields []string
|
||||
field := ""
|
||||
for _, c := range expr {
|
||||
if c == ' ' || c == '\t' {
|
||||
if field != "" {
|
||||
fields = append(fields, field)
|
||||
field = ""
|
||||
}
|
||||
} else {
|
||||
field += string(c)
|
||||
}
|
||||
}
|
||||
if field != "" {
|
||||
fields = append(fields, field)
|
||||
}
|
||||
|
||||
if len(fields) < 5 {
|
||||
return now.Add(time.Hour) // Unparseable — retry in 1h
|
||||
}
|
||||
|
||||
minute := parseField(fields[0], 0)
|
||||
hour := parseField(fields[1], 0)
|
||||
|
||||
// Construct today's target time
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if target.After(now) {
|
||||
return target
|
||||
}
|
||||
|
||||
// Check day-of-week field
|
||||
if fields[4] != "*" {
|
||||
dow := parseField(fields[4], -1)
|
||||
if dow >= 0 && dow <= 6 {
|
||||
// Advance to next matching day
|
||||
for i := 1; i <= 7; i++ {
|
||||
candidate := target.AddDate(0, 0, i)
|
||||
if int(candidate.Weekday()) == dow {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: next day at same time
|
||||
return target.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
// parseField parses a single cron field. Returns def for "*" or errors.
|
||||
func parseField(s string, def int) int {
|
||||
if s == "*" {
|
||||
return def
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
} else {
|
||||
return def // step/range/list — not supported in minimal parser
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user