You've already forked agentic-coding-workflow
895799a6c9
Module.Stop used time.After which leaks the timer if the done channel
wins first; switch to time.NewTimer + defer Stop() to align with the
codebase convention.
scheduleEntry silently returned {0, false} for unknown runner names —
typos in production wiring were invisible. Return (entry, known) and
log Warn at NewModule registration time so misconfiguration surfaces.
135 lines
3.5 KiB
Go
135 lines
3.5 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
|
)
|
|
|
|
// RunnerFunc is the signature of a periodic runner's tick.
|
|
type RunnerFunc func(ctx context.Context)
|
|
|
|
// ModuleDeps groups the externally-supplied collaborators.
|
|
type ModuleDeps struct {
|
|
Repo Repository
|
|
Handlers map[JobType]Handler
|
|
RunnerFns map[string]RunnerFunc // name → tick fn (e.g. "workspace_fetch")
|
|
Clock clock.Clock
|
|
Log *slog.Logger
|
|
WorkerBackoff []time.Duration
|
|
OnDeadLetter func(ctx context.Context, job Job, err error)
|
|
}
|
|
|
|
// Module wires the scheduler, worker pool and runners. Owners construct it via
|
|
// NewModule + Start, then call Stop on shutdown.
|
|
type Module struct {
|
|
cfg Config
|
|
scheduler *Scheduler
|
|
workers []*Worker
|
|
log *slog.Logger
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewModule constructs but does not start the module.
|
|
func NewModule(cfg Config, deps ModuleDeps) *Module {
|
|
clk := deps.Clock
|
|
if clk == nil {
|
|
clk = clock.Real()
|
|
}
|
|
log := deps.Log
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
|
|
sch := NewScheduler(log)
|
|
for name, fn := range deps.RunnerFns {
|
|
entry, known := scheduleEntry(cfg, name)
|
|
if !known {
|
|
log.Warn("jobs.module.unknown_runner", "name", name)
|
|
}
|
|
sch.Add(name, entry.interval, entry.enabled, fn)
|
|
}
|
|
|
|
workers := make([]*Worker, cfg.Workers)
|
|
for i := 0; i < cfg.Workers; i++ {
|
|
workers[i] = NewWorker(deps.Repo, deps.Handlers, WorkerOptions{
|
|
ID: workerID(i),
|
|
PollInterval: 200 * time.Millisecond,
|
|
Backoff: deps.WorkerBackoff,
|
|
Clock: clk,
|
|
Log: log,
|
|
OnDeadLetter: deps.OnDeadLetter,
|
|
})
|
|
}
|
|
return &Module{cfg: cfg, scheduler: sch, workers: workers, log: log}
|
|
}
|
|
|
|
// Start kicks off the scheduler and worker goroutines. They exit when ctx is
|
|
// canceled. Caller must call Stop to wait for clean shutdown.
|
|
func (m *Module) Start(ctx context.Context) {
|
|
if !m.cfg.Enabled {
|
|
m.log.Warn("jobs.module.disabled")
|
|
return
|
|
}
|
|
m.scheduler.Start(ctx)
|
|
for _, w := range m.workers {
|
|
m.wg.Add(1)
|
|
go func(w *Worker) {
|
|
defer m.wg.Done()
|
|
w.Run(ctx)
|
|
}(w)
|
|
}
|
|
m.log.Info("jobs.module.started", "workers", len(m.workers))
|
|
}
|
|
|
|
// Stop waits up to ShutdownGrace for goroutines to exit.
|
|
func (m *Module) Stop(ctx context.Context) {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
m.scheduler.Wait()
|
|
m.wg.Wait()
|
|
close(done)
|
|
}()
|
|
grace := m.cfg.ShutdownGrace
|
|
if grace <= 0 {
|
|
grace = 30 * time.Second
|
|
}
|
|
timer := time.NewTimer(grace)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-done:
|
|
m.log.Info("jobs.module.stopped")
|
|
case <-timer.C:
|
|
m.log.Warn("jobs.module.shutdown_timeout", "grace_ms", grace.Milliseconds())
|
|
case <-ctx.Done():
|
|
m.log.Warn("jobs.module.shutdown_ctx_canceled")
|
|
}
|
|
}
|
|
|
|
func workerID(i int) string { return "worker#" + strconv.Itoa(i) }
|
|
|
|
type schedEntry struct {
|
|
interval time.Duration
|
|
enabled bool
|
|
}
|
|
|
|
func scheduleEntry(cfg Config, name string) (schedEntry, bool) {
|
|
switch name {
|
|
case "workspace_fetch":
|
|
return schedEntry{cfg.WorkspaceFetch.Interval, cfg.WorkspaceFetch.Enabled}, true
|
|
case "worktree_prune":
|
|
return schedEntry{cfg.WorktreePrune.Interval, cfg.WorktreePrune.Enabled}, true
|
|
case "attachment_cleanup":
|
|
return schedEntry{cfg.AttachmentCleanup.Interval, cfg.AttachmentCleanup.Enabled}, true
|
|
case "job_reaper":
|
|
return schedEntry{cfg.JobReaper.Interval, cfg.JobReaper.Enabled}, true
|
|
case "job_purge":
|
|
return schedEntry{cfg.JobPurge.Interval, cfg.JobPurge.Enabled}, true
|
|
}
|
|
return schedEntry{0, false}, false
|
|
}
|