Changeset 0.27.2 (#169)

This commit is contained in:
2026-03-11 00:22:02 +00:00
parent e4efe6b934
commit dcb915555e
20 changed files with 2106 additions and 880 deletions

49
server/scheduler/cron.go Normal file
View File

@@ -0,0 +1,49 @@
package scheduler
import (
"time"
"github.com/robfig/cron/v3"
)
// cronParser is a shared parser instance. Standard 5-field cron with
// optional descriptors (@hourly, @daily, @weekly, @monthly, etc.).
var cronParser = cron.NewParser(
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)
// NextRunFromSchedule computes the next run time from a cron expression
// and timezone. Returns nil for "once" schedules (one-shot tasks).
//
// Replaces the v0.27.1 hand-rolled parseDailyCron with full 5-field
// cron support via robfig/cron/v3.
func NextRunFromSchedule(schedule, timezone string) *time.Time {
if schedule == "once" {
return nil
}
now := time.Now()
if tz, err := time.LoadLocation(timezone); err == nil {
now = now.In(tz)
}
sched, err := cronParser.Parse(schedule)
if err != nil {
// Unparseable — log at call site, caller decides fallback
return nil
}
next := sched.Next(now).UTC()
return &next
}
// ValidateCron checks whether a cron expression is valid.
// Returns nil for valid expressions, error describing the problem otherwise.
// "once" is always valid (one-shot schedule).
func ValidateCron(schedule string) error {
if schedule == "once" {
return nil
}
_, err := cronParser.Parse(schedule)
return err
}

View File

@@ -0,0 +1,271 @@
// Package scheduler — executor.go
//
// v0.27.2: Headless task execution via coreToolLoop.
//
// The Executor bridges the task scheduler with the completion pipeline.
// It resolves providers, builds tool definitions, runs the core tool loop
// with budget enforcement, persists results, and sends notifications.
package scheduler
import (
"context"
"encoding/json"
"log"
"time"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
// Executor runs task completions headlessly (no HTTP client).
type Executor struct {
stores store.Stores
vault *crypto.KeyResolver
hub *events.Hub
health handlers.HealthRecorder
}
// NewExecutor creates a task executor. All fields are optional except stores.
func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub, health handlers.HealthRecorder) *Executor {
return &Executor{
stores: stores,
vault: vault,
hub: hub,
health: health,
}
}
// Execute runs a single task to completion.
// Called from the scheduler's execute() goroutine.
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
startTime := time.Now()
// ── 1. Resolve provider ────────────────────
providerConfigID := ""
if task.ProviderConfigID != nil {
providerConfigID = *task.ProviderConfigID
}
res, err := handlers.ResolveProviderConfig(e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID)
if err != nil {
e.failRun(ctx, task, run, "provider resolution failed: "+err.Error())
return
}
provider, err := providers.Get(res.ProviderID)
if err != nil {
e.failRun(ctx, task, run, "provider unavailable: "+err.Error())
return
}
// ── 2. Build messages ──────────────────────
messages := make([]providers.Message, 0, 3)
// System prompt: task-level > persona > empty
systemPrompt := task.SystemPrompt
if systemPrompt == "" && task.PersonaID != nil && e.stores.Personas != nil {
if persona, err := e.stores.Personas.GetByID(ctx, *task.PersonaID); err == nil {
systemPrompt = persona.SystemPrompt
}
}
if systemPrompt != "" {
messages = append(messages, providers.Message{
Role: "system",
Content: systemPrompt,
})
}
// User prompt
if task.UserPrompt != "" {
messages = append(messages, providers.Message{
Role: "user",
Content: task.UserPrompt,
})
}
// ── 3. Build tool definitions ──────────────
caps := capspkg.InferCapabilities(res.Model)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(res.Model, caps)
var toolDefs []providers.ToolDef
if caps.ToolCalling {
personaID := ""
if task.PersonaID != nil {
personaID = *task.PersonaID
}
tctx := tools.ToolContext{
ChannelType: "service",
PersonaID: personaID,
}
// No browser tools for headless execution
toolDefs = handlers.BuildToolDefs(ctx, e.stores, task.OwnerID, false, nil, tctx, personaID)
// Apply task-level tool grants
if len(task.ToolGrants) > 0 {
var grants []string
if json.Unmarshal(task.ToolGrants, &grants) == nil && len(grants) > 0 {
toolDefs = handlers.FilterToolDefsByGrants(toolDefs, grants)
}
}
}
// ── 4. Build completion request ────────────
req := providers.CompletionRequest{
Model: res.Model,
Messages: messages,
Tools: toolDefs,
}
if task.MaxTokens > 0 {
req.MaxTokens = task.MaxTokens
} else if caps.MaxOutputTokens > 0 {
req.MaxTokens = caps.MaxOutputTokens
}
// Apply provider-specific request hooks
if hooks := providers.GetHooks(res.ProviderID); hooks != nil {
hooks.PreRequest(res.Config, &req)
}
// ── 5. Execute via core tool loop ──────────
personaID := ""
if task.PersonaID != nil {
personaID = *task.PersonaID
}
sink := handlers.NewHeadlessSink(task.ID)
result := handlers.CoreToolLoop(ctx, handlers.LoopConfig{
Provider: provider,
Cfg: res.Config,
Req: &req,
Model: res.Model,
ProviderType: res.ProviderID,
ExecCtx: tools.ExecutionContext{
UserID: task.OwnerID,
ChannelID: channelID,
PersonaID: personaID,
},
Hub: e.hub,
Health: e.health,
ConfigID: res.ConfigID,
Budget: handlers.LoopBudget{
MaxRounds: 0, // use default
MaxToolCalls: task.MaxToolCalls,
MaxTokens: task.MaxTokens,
},
Streaming: false, // headless — use ChatCompletion
}, sink)
// ── 6. Persist assistant response ──────────
wallClock := int(time.Since(startTime).Seconds())
if result.Content != "" && e.stores.Messages != nil {
_ = e.stores.Messages.Create(ctx, &models.Message{
ChannelID: channelID,
Role: "assistant",
Content: result.Content,
Model: res.Model,
})
}
// ── 7. Determine terminal status ───────────
status := "completed"
errMsg := ""
if result.Error != nil {
status = "failed"
errMsg = result.Error.Error()
} else if result.BudgetExceeded != "" {
status = "budget_exceeded"
errMsg = "budget exceeded: " + result.BudgetExceeded
}
// ── 8. Update run record ───────────────────
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status,
result.InputTokens+result.OutputTokens,
result.ToolCallCount,
wallClock,
errMsg)
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
log.Printf("[executor] Task %s (%s) → %s (tokens=%d, tools=%d, wall=%ds)",
task.ID, task.Name, status,
result.InputTokens+result.OutputTokens,
result.ToolCallCount, wallClock)
// ── 9. Owner notification ──────────────────
e.notifyOwner(ctx, task, status, errMsg)
}
// failRun marks a run as failed before completion was attempted.
func (e *Executor) failRun(ctx context.Context, task models.Task, run *models.TaskRun, errMsg string) {
log.Printf("[executor] Task %s (%s) pre-execution failure: %s", task.ID, task.Name, errMsg)
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, "failed", 0, 0, 0, errMsg)
e.notifyOwner(ctx, task, "failed", errMsg)
}
// notifyOwner sends a notification based on task outcome and preferences.
func (e *Executor) notifyOwner(ctx context.Context, task models.Task, status, errMsg string) {
notifSvc := notifications.Default()
if notifSvc == nil {
return
}
switch status {
case "completed":
if !task.NotifyOnComplete {
return
}
_ = notifSvc.Notify(ctx, &models.Notification{
UserID: task.OwnerID,
Type: "task.completed",
Title: "Task completed: " + task.Name,
Body: "Scheduled task finished successfully.",
ResourceType: "channel",
ResourceID: stringVal(task.OutputChannelID),
})
case "failed":
if !task.NotifyOnFailure {
return
}
body := "Scheduled task failed."
if errMsg != "" {
body = errMsg
if len(body) > 200 {
body = body[:200] + "…"
}
}
_ = notifSvc.Notify(ctx, &models.Notification{
UserID: task.OwnerID,
Type: "task.failed",
Title: "Task failed: " + task.Name,
Body: body,
ResourceType: "channel",
ResourceID: stringVal(task.OutputChannelID),
})
case "budget_exceeded":
// Always notify on budget breach regardless of preference
_ = notifSvc.Notify(ctx, &models.Notification{
UserID: task.OwnerID,
Type: "task.budget_exceeded",
Title: "Task budget exceeded: " + task.Name,
Body: errMsg,
ResourceType: "channel",
ResourceID: stringVal(task.OutputChannelID),
})
}
}
func stringVal(s *string) string {
if s == nil {
return ""
}
return *s
}

View File

@@ -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
}

View File

@@ -0,0 +1,110 @@
package scheduler
import (
"context"
"log"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// TaskConfig holds the runtime task configuration read from global_settings.
// Keys: tasks.enabled, tasks.allow_personal, tasks.max_concurrent,
// tasks.default_max_tokens, tasks.default_max_tool_calls,
// tasks.default_max_wall_clock
type TaskConfig struct {
Enabled bool
AllowPersonal bool
MaxConcurrent int
DefaultMaxTokens int
DefaultMaxToolCalls int
DefaultMaxWallClock int // seconds
}
// DefaultTaskConfig returns sensible defaults when no global config is set.
func DefaultTaskConfig() TaskConfig {
return TaskConfig{
Enabled: true,
AllowPersonal: true,
MaxConcurrent: 5,
DefaultMaxTokens: 4096,
DefaultMaxToolCalls: 10,
DefaultMaxWallClock: 300,
}
}
// LoadTaskConfig reads task configuration from global_settings.
// Falls back to defaults for missing keys.
func LoadTaskConfig(ctx context.Context, gc store.GlobalConfigStore) TaskConfig {
cfg := DefaultTaskConfig()
if gc == nil {
return cfg
}
raw, err := gc.Get(ctx, "tasks")
if err != nil || raw == nil {
return cfg
}
if v, ok := boolVal(raw, "enabled"); ok {
cfg.Enabled = v
}
if v, ok := boolVal(raw, "allow_personal"); ok {
cfg.AllowPersonal = v
}
if v, ok := intVal(raw, "max_concurrent"); ok && v > 0 {
cfg.MaxConcurrent = v
}
if v, ok := intVal(raw, "default_max_tokens"); ok && v > 0 {
cfg.DefaultMaxTokens = v
}
if v, ok := intVal(raw, "default_max_tool_calls"); ok && v > 0 {
cfg.DefaultMaxToolCalls = v
}
if v, ok := intVal(raw, "default_max_wall_clock"); ok && v > 0 {
cfg.DefaultMaxWallClock = v
}
return cfg
}
// ApplyDefaults fills zero-value budget fields on a task with the global defaults.
func (tc TaskConfig) ApplyDefaults(t *models.Task) {
if t.MaxTokens == 0 {
t.MaxTokens = tc.DefaultMaxTokens
}
if t.MaxToolCalls == 0 {
t.MaxToolCalls = tc.DefaultMaxToolCalls
}
if t.MaxWallClock == 0 {
t.MaxWallClock = tc.DefaultMaxWallClock
}
}
// ── helpers ────────────────────────────────────
func boolVal(m models.JSONMap, key string) (bool, bool) {
v, ok := m[key]
if !ok {
return false, false
}
b, ok := v.(bool)
return b, ok
}
func intVal(m models.JSONMap, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
// JSON numbers are float64 after Unmarshal
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
default:
log.Printf("[task_config] unexpected type for %s: %T", key, v)
return 0, false
}
}