You've already forked agentic-coding-workflow
163 lines
4.9 KiB
Go
163 lines
4.9 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)
|
|
}
|
|
|
|
handlerTimeout := handlerTimeoutFor(cfg.JobReaper)
|
|
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,
|
|
HandlerTimeout: handlerTimeout,
|
|
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) }
|
|
|
|
// handlerTimeoutFor derives the per-job handler timeout from the reaper's lease
|
|
// timeout, leaving a margin below it so a healthy long handler finishes (or is
|
|
// canceled) before the reaper re-leases the job and double-runs it. Returns 0
|
|
// (no timeout) when reaping is disabled or no positive lease timeout is set.
|
|
func handlerTimeoutFor(rc JobReaperConfig) time.Duration {
|
|
if !rc.Enabled || rc.LeaseTimeout <= 0 {
|
|
return 0
|
|
}
|
|
// 90% of the lease window leaves headroom for the write-back to land before
|
|
// the reaper's cutoff.
|
|
return rc.LeaseTimeout - rc.LeaseTimeout/10
|
|
}
|
|
|
|
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
|
|
case "acp_events_purge":
|
|
return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true
|
|
case "acp_turns_purge":
|
|
// 复用 acp_events 的保留/调度配置:回合记录与事件同源、同生命周期。
|
|
return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true
|
|
case "mcp_tokens_purge":
|
|
return schedEntry{cfg.MCPTokensPurge.Interval, cfg.MCPTokensPurge.Enabled}, true
|
|
case "acp_session_reaper":
|
|
return schedEntry{cfg.AcpSessionReaper.Interval, cfg.AcpSessionReaper.Enabled}, true
|
|
case "orchestrator_schedule":
|
|
return schedEntry{cfg.OrchestratorSchedule.Interval, cfg.OrchestratorSchedule.Enabled}, true
|
|
case "audit_retention":
|
|
return schedEntry{cfg.AuditRetention.Interval, cfg.AuditRetention.Enabled}, true
|
|
}
|
|
return schedEntry{0, false}, false
|
|
}
|