Files
odidere/internal/job/job.go
T

165 lines
5.0 KiB
Go
Raw Normal View History

2026-06-02 12:03:03 +00:00
// Package job provides cron-based scheduling for autonomous LLM tasks.
//
// Jobs are defined in the YAML configuration with a cron expression,
2026-10-30 00:45:54 +00:00
// 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].
2026-06-02 12:03:03 +00:00
package job
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/robfig/cron/v3"
2026-06-19 14:31:41 +00:00
"code.chimeric.al/chimerical/odidere/internal/config"
2026-10-30 00:45:54 +00:00
"code.chimeric.al/chimerical/odidere/internal/conversation"
2026-06-02 12:03:03 +00:00
"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 {
2026-10-07 00:23:08 +00:00
// cron is the cron.Schedule derived from schedule during construction.
2026-06-02 12:03:03 +00:00
cron cron.Schedule
// llm is the LLM client used to execute the job.
llm *llm.Client
2026-10-07 00:23:08 +00:00
// log is the structured logger used for job execution.
log *slog.Logger
// maxIterations caps the agent loop iterations. Zero means unlimited.
2026-06-02 12:03:03 +00:00
maxIterations int
2026-10-07 00:23:08 +00:00
// model is the model ID to pass to the LLM client.
model string
// name uniquely identifies the job within the config.
2026-06-02 12:03:03 +00:00
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
2026-10-07 00:23:08 +00:00
// timeout is the parsed execution time limit.
2026-10-30 00:45:54 +00:00
// Zero means no per-job timeout.
2026-06-02 12:03:03 +00:00
timeout time.Duration
2026-10-07 00:23:08 +00:00
// userId identifies the owner of conversations created by the job.
userId string
2026-06-02 12:03:03 +00:00
}
2026-10-07 00:23:08 +00:00
// 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 {
2026-10-10 22:51:03 +00:00
return nil, fmt.Errorf("missing llm client")
2026-10-07 00:23:08 +00:00
}
if log == nil {
2026-10-10 22:51:03 +00:00
return nil, fmt.Errorf("missing logger")
2026-10-07 00:23:08 +00:00
}
2026-06-02 12:03:03 +00:00
sched, err := cron.ParseStandard(cfg.Schedule)
if err != nil {
return nil, fmt.Errorf(
"invalid schedule %q: %w", cfg.Schedule, err,
)
}
return &Job{
cron: sched,
2026-06-19 14:31:41 +00:00
llm: llmc,
2026-10-07 00:23:08 +00:00
log: log.With(slog.String("job", cfg.Name)),
2026-06-02 12:03:03 +00:00
maxIterations: cfg.MaxIterations,
2026-10-07 00:23:08 +00:00
model: cfg.Model.Model,
2026-06-02 12:03:03 +00:00
name: cfg.Name,
schedule: cfg.Schedule,
systemMessage: cfg.SystemMessage,
task: cfg.Task,
2026-10-07 00:23:08 +00:00
timeout: cfg.Timeout,
userId: cfg.UserId,
2026-06-02 12:03:03 +00:00
}, nil
}
// Cron returns the parsed cron schedule.
2026-06-19 14:31:41 +00:00
func (j *Job) Cron() cron.Schedule { return j.cron }
2026-06-02 12:03:03 +00:00
2026-10-30 00:45:54 +00:00
// MaxIterations returns the job's iteration limit. Zero means unlimited.
2026-06-19 14:31:41 +00:00
func (j *Job) MaxIterations() int { return j.maxIterations }
2026-06-02 12:03:03 +00:00
2026-10-30 00:45:54 +00:00
// Model returns the model ID.
2026-06-19 14:31:41 +00:00
func (j *Job) Model() string { return j.model }
2026-06-02 12:03:03 +00:00
// Name returns the job name.
2026-06-19 14:31:41 +00:00
func (j *Job) Name() string { return j.name }
2026-06-02 12:03:03 +00:00
2026-10-30 00:45:54 +00:00
// 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()
2026-10-07 00:23:08 +00:00
j.log.InfoContext(ctx, "starting job")
2026-10-30 00:45:54 +00:00
inf, err := c.Reserve(ctx, j.llm, conversation.InferenceRequest{
Message: &llm.Message{
Role: llm.RoleUser,
ContentParts: []llm.ContentPart{{
2026-10-07 00:23:08 +00:00
Type: llm.ContentTypeText,
2026-10-30 00:45:54 +00:00
Text: j.task,
}},
2026-10-07 00:23:08 +00:00
},
2026-10-30 00:45:54 +00:00
Model: j.model,
MaxIterations: j.maxIterations,
Timeout: j.timeout,
2026-10-07 00:23:08 +00:00
})
2026-10-30 00:45:54 +00:00
if err == nil {
err = inf.Run()
2026-10-07 00:23:08 +00:00
}
2026-10-30 00:45:54 +00:00
duration := time.Since(start)
2026-10-07 00:23:08 +00:00
if err != nil {
j.log.ErrorContext(
ctx,
"job failed",
slog.Any("error", err),
slog.Duration("duration", duration),
)
2026-10-30 00:45:54 +00:00
return fmt.Errorf("job %q: %w", j.name, err)
2026-10-07 00:23:08 +00:00
}
2026-10-30 00:45:54 +00:00
j.log.InfoContext(
ctx, "job completed", slog.Duration("duration", duration),
2026-10-07 00:23:08 +00:00
)
2026-10-30 00:45:54 +00:00
return nil
2026-10-07 00:23:08 +00:00
}
2026-06-19 14:31:41 +00:00
// Schedule returns the job schedule string.
func (j *Job) Schedule() string { return j.schedule }
2026-06-02 12:03:03 +00:00
// SystemMessage returns the job's system message.
2026-06-19 14:31:41 +00:00
func (j *Job) SystemMessage() string { return j.systemMessage }
2026-06-02 12:03:03 +00:00
// Task returns the job's task description.
2026-06-19 14:31:41 +00:00
func (j *Job) Task() string { return j.task }
2026-06-02 12:03:03 +00:00
// Timeout returns the job's timeout duration.
2026-06-19 14:31:41 +00:00
func (j *Job) Timeout() time.Duration { return j.timeout }
2026-10-30 00:45:54 +00:00
// UserId returns the owner of conversations created by the job.
func (j *Job) UserId() string { return j.userId }