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
+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.