// Package job provides cron-based scheduling for autonomous LLM tasks. // // Jobs are defined in the YAML configuration with a cron expression, // a system message, and a task description. The service creates a // conversation for each firing; [Job.Run] runs the job's task in that // conversation through [conversation.Conversation.Reserve]. package job import ( "context" "fmt" "log/slog" "time" "github.com/robfig/cron/v3" "code.chimeric.al/chimerical/odidere/internal/config" "code.chimeric.al/chimerical/odidere/internal/conversation" "code.chimeric.al/chimerical/odidere/internal/llm" ) // Job defines a scheduled autonomous task. // // Job is immutable after construction via New. All fields are private and // accessed through getter methods. This ensures that a Job's configuration // cannot change once it is handed to the service. type Job struct { // cron is the cron.Schedule derived from schedule during construction. cron cron.Schedule // llm is the LLM client used to execute the job. llm *llm.Client // log is the structured logger used for job execution. log *slog.Logger // maxIterations caps the agent loop iterations. Zero means unlimited. maxIterations int // model is the model ID to pass to the LLM client. model string // name uniquely identifies the job within the config. name string // schedule is the original cron expression string from the config // (e.g. "0 9 * * *"). Stored for logging and display purposes. schedule string // systemMessage is the per-job system prompt that overrides the global // LLM system message. Empty string means use the default. systemMessage string // task is the user-level instruction passed to the LLM when the job // fires. This is the core of what the job does. task string // timeout is the parsed execution time limit. // Zero means no per-job timeout. timeout time.Duration // userId identifies the owner of conversations created by the job. userId string } // New constructs a Job from cfg and its runtime dependencies. New parses // values required for execution and rejects dependencies that Run cannot // operate without. log must be non-nil; pass slog.New(slog.DiscardHandler) // to run without logs. func New( cfg config.JobConfig, llmc *llm.Client, log *slog.Logger, ) (*Job, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } if llmc == nil { return nil, fmt.Errorf("missing llm client") } if log == nil { return nil, fmt.Errorf("missing logger") } sched, err := cron.ParseStandard(cfg.Schedule) if err != nil { return nil, fmt.Errorf( "invalid schedule %q: %w", cfg.Schedule, err, ) } return &Job{ cron: sched, llm: llmc, log: log.With(slog.String("job", cfg.Name)), maxIterations: cfg.MaxIterations, model: cfg.Model.Model, name: cfg.Name, schedule: cfg.Schedule, systemMessage: cfg.SystemMessage, task: cfg.Task, timeout: cfg.Timeout, userId: cfg.UserId, }, nil } // Cron returns the parsed cron schedule. func (j *Job) Cron() cron.Schedule { return j.cron } // MaxIterations returns the job's iteration limit. Zero means unlimited. func (j *Job) MaxIterations() int { return j.maxIterations } // Model returns the model ID. func (j *Job) Model() string { return j.model } // Name returns the job name. func (j *Job) Name() string { return j.name } // Run sends the job's task as a user message to c and runs inference // until it returns. c must already hold the job's system message, if // any; Run does not add it. // // The caller registers c before Run, so clients can subscribe to the // job and stop it like any other conversation. Run returns // [conversation.ErrBusy] if c is already reserved. func (j *Job) Run(ctx context.Context, c *conversation.Conversation) error { start := time.Now() j.log.InfoContext(ctx, "starting job") inf, err := c.Reserve(ctx, j.llm, conversation.InferenceRequest{ Message: &llm.Message{ Role: llm.RoleUser, ContentParts: []llm.ContentPart{{ Type: llm.ContentTypeText, Text: j.task, }}, }, Model: j.model, MaxIterations: j.maxIterations, Timeout: j.timeout, }) if err == nil { err = inf.Run() } duration := time.Since(start) if err != nil { j.log.ErrorContext( ctx, "job failed", slog.Any("error", err), slog.Duration("duration", duration), ) return fmt.Errorf("job %q: %w", j.name, err) } j.log.InfoContext( ctx, "job completed", slog.Duration("duration", duration), ) return nil } // Schedule returns the job schedule string. func (j *Job) Schedule() string { return j.schedule } // SystemMessage returns the job's system message. func (j *Job) SystemMessage() string { return j.systemMessage } // Task returns the job's task description. func (j *Job) Task() string { return j.task } // Timeout returns the job's timeout duration. func (j *Job) Timeout() time.Duration { return j.timeout } // UserId returns the owner of conversations created by the job. func (j *Job) UserId() string { return j.userId }