feat: per-job command timeout with global default

Add an optional per-job run timeout following the overlap_policy inherit
pattern: Job.TimeoutSeconds (0 = inherit) resolves against a new
Config.DefaultTimeoutSeconds (default 30s), replacing the hard-coded 30s
guard in runner.RunJob.

- domain/storage: new fields, default 30, load-time normalization
- runner: RunJob takes an explicit timeout; StartOnly stays untimed so it
  keeps measuring launch latency only
- app: effectiveTimeout resolves under mu into runEnv, threaded to runJob;
  seam signature and validation updated; DisplayTimeout helper
- ui: Timeout entry in the job dialog, Default timeout in Settings, and a
  Timeout row in the details panel
- tests + docs (ARCHITECTURE, STANDARDS, ROADMAP, CHANGELOG) updated;
  version bumped to 0.12.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-07-25 23:24:50 +03:00
parent f4221f6ce4
commit 48faddb3bd
23 changed files with 270 additions and 87 deletions
+11
View File
@@ -102,6 +102,17 @@ func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) str
return string(globalPolicy) + " (global default)"
}
// DisplayTimeout formats a job's effective run timeout for the details panel.
// When the job sets its own TimeoutSeconds it is shown as-is; when 0 (inherit),
// the global default is shown with "(global default)" appended, mirroring
// DisplayOverlapPolicy.
func DisplayTimeout(job domain.Job, globalDefault int) string {
if job.TimeoutSeconds > 0 {
return fmt.Sprintf("%d s", job.TimeoutSeconds)
}
return fmt.Sprintf("%d s (global default)", globalDefault)
}
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
// or 0 if not found.
func DisplayIndex(indexes []int, jobIndex int) int {
+11
View File
@@ -163,3 +163,14 @@ func TestDisplayOverlapPolicy(t *testing.T) {
t.Errorf("inherited policy = %q, want %q", got, want)
}
}
func TestDisplayTimeout(t *testing.T) {
own := domain.Job{TimeoutSeconds: 45}
if got, want := DisplayTimeout(own, 30), "45 s"; got != want {
t.Errorf("per-job timeout = %q, want %q", got, want)
}
inherit := domain.Job{TimeoutSeconds: 0}
if got, want := DisplayTimeout(inherit, 30), "30 s (global default)"; got != want {
t.Errorf("inherited timeout = %q, want %q", got, want)
}
}
+6
View File
@@ -367,6 +367,9 @@ func validateJob(job domain.Job) error {
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
return errors.New("overlap policy must be 'skip', 'queue', or empty")
}
if job.TimeoutSeconds < 0 {
return errors.New("timeout must be zero (inherit) or a positive number of seconds")
}
return nil
}
@@ -390,5 +393,8 @@ func validateConfig(config domain.Config) error {
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
return errors.New("overlap policy must be 'skip' or 'queue'")
}
if config.DefaultTimeoutSeconds <= 0 {
return errors.New("default timeout must be a positive number of seconds")
}
return nil
}
+14 -10
View File
@@ -27,7 +27,7 @@ func newTempService(t *testing.T, jobs []domain.Job) *Service {
JobsPath: filepath.Join(dir, "jobs.json"),
LogsDir: filepath.Join(dir, "logs"),
},
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip},
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30},
}
return NewService(store, jobs)
}
@@ -103,6 +103,9 @@ func TestCreateJobValidates(t *testing.T) {
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil {
t.Error("expected error for invalid overlap policy")
}
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: -1}); err == nil {
t.Error("expected error for negative per-job timeout")
}
}
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
@@ -250,7 +253,7 @@ func TestRunNowUsesRunnerAndRecords(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Manual" {
t.Errorf("trigger = %q, want Manual", trigger)
}
@@ -298,7 +301,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
entered := make(chan struct{}, 1)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- struct{}{}
<-release
@@ -341,7 +344,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
func TestRunNowAllowedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select {
case done <- struct{}{}:
default:
@@ -366,7 +369,7 @@ func TestRunDueStartsDueJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Schedule" {
t.Errorf("trigger = %q, want Schedule", trigger)
}
@@ -397,7 +400,7 @@ func TestRunDueStartsDueJob(t *testing.T) {
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
}
@@ -418,7 +421,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var calls int32
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
return domain.RunRecord{State: "Success"}, nil
}
@@ -442,7 +445,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
}
@@ -472,7 +475,7 @@ func TestStartDrivesRunDueOnTick(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select {
case done <- struct{}{}:
default:
@@ -524,6 +527,7 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"non-positive default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = 0 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -596,7 +600,7 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
var ran int32
runStarted := make(chan struct{}, 1)
svc2.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
svc2.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
select {
case runStarted <- struct{}{}:
+16 -1
View File
@@ -112,6 +112,7 @@ type runEnv struct {
logsDir string
maxFiles int
maxAge int
timeout time.Duration
}
// startRunLocked transitions a job to "Running", advances its NextDue to the next
@@ -142,6 +143,7 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
logsDir: s.store.Paths.LogsDir,
maxFiles: s.store.Config.MaxLogFiles,
maxAge: s.store.Config.MaxLogAgeDays,
timeout: s.effectiveTimeout(job),
}
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
// from under the goroutine after we release mu.
@@ -155,7 +157,7 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
// is not paused, deferred runs are started one at a time until PendingRuns reaches
// zero. Each deferred run runs on its own goroutine.
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir)
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
s.mu.Lock()
var cleanupErr, saveErr error
@@ -208,6 +210,19 @@ func (s *Service) effectiveOverlapPolicy(job *domain.Job) domain.OverlapPolicy {
return s.store.Config.OverlapPolicy
}
// effectiveTimeout resolves the run timeout that actually governs a job: the
// job's own TimeoutSeconds when positive, otherwise the global
// Config.DefaultTimeoutSeconds. A non-positive Job.TimeoutSeconds means "inherit
// the global default", which is why normalizeJob leaves 0 rather than
// backfilling the configured value. The caller must hold mu.
func (s *Service) effectiveTimeout(job *domain.Job) time.Duration {
secs := job.TimeoutSeconds
if secs <= 0 {
secs = s.store.Config.DefaultTimeoutSeconds
}
return time.Duration(secs) * time.Second
}
// anyRunningLocked reports whether any loaded job is currently in the "Running"
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller
// must hold mu.
+29 -11
View File
@@ -135,7 +135,7 @@ func TestRunDueParallelStartsAllDueJobs(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -176,7 +176,7 @@ func TestRunDueSequentialSerializes(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -213,7 +213,7 @@ func TestRunDueSkipDropsOverlap(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
@@ -259,7 +259,7 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
@@ -314,7 +314,7 @@ func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
if atomic.LoadInt32(&calls) == 0 {
<-release
}
@@ -366,7 +366,7 @@ func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
@@ -416,7 +416,7 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
@@ -464,7 +464,7 @@ func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -506,7 +506,7 @@ func TestRunNowSequentialGuard(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -547,7 +547,7 @@ func TestStartRunLockedRollbackOnSaveFailure(t *testing.T) {
t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) })
var started int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&started, 1)
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
}
@@ -573,7 +573,7 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
@@ -616,3 +616,21 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
t.Errorf("runner called %d time(s), want 1", got)
}
}
// TestEffectiveTimeout verifies the inherit-or-override resolution: a zero
// Job.TimeoutSeconds falls back to the global default, while a positive value
// overrides it.
func TestEffectiveTimeout(t *testing.T) {
svc := newTempService(t, nil)
svc.store.Config.DefaultTimeoutSeconds = 30
inherit := &domain.Job{TimeoutSeconds: 0}
if got, want := svc.effectiveTimeout(inherit), 30*time.Second; got != want {
t.Errorf("inherited timeout = %s, want %s", got, want)
}
own := &domain.Job{TimeoutSeconds: 5}
if got, want := svc.effectiveTimeout(own), 5*time.Second; got != want {
t.Errorf("per-job timeout = %s, want %s", got, want)
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ type Service struct {
// processes. ctx is the lifecycle context passed to runs; Start replaces it
// with a cancelable context so Stop can abort in-flight runs, and until Start
// it is context.Background().
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error)
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error)
ctx context.Context
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
+1 -1
View File
@@ -3,4 +3,4 @@ package app
// Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build.
var Version = "0.11.5"
var Version = "0.12.0"