Compare commits

...

2 Commits

Author SHA1 Message Date
mixeme b9505e83ab domain/config.go + app: persist global pause state across restarts (T4.1, T4.2)
Add Config.Paused bool so the scheduler's paused flag survives a restart.
SetGlobalPause now writes the flag into store.Config and calls SaveConfig;
NewService seeds s.paused from Config.Paused before computing first next-run
times, so jobs show "Scheduler paused" immediately at startup when paused.
Also add Paused to the yamlConfig shadow struct to keep the direct conversion
from domain.Config valid until the YAML import path is dropped in T6.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:16:43 +03:00
mixeme b0d9883627 app/run_test.go: per-job overlap policy override tests (T3.4)
Adds three tests for the effective-policy resolution:
- per-job "queue" overrides global "skip" (job marked Pending, re-runs)
- per-job "skip" overrides global "queue" (overlap dropped, no re-run)
- empty Job.OverlapPolicy inherits the global default

Also ticks Phase 3 complete in docs/RELEASE-0.10-TASKS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:33:06 +03:00
6 changed files with 160 additions and 12 deletions
+4 -4
View File
@@ -97,10 +97,10 @@ Done first because both share a compact, single-line record formatter.
- [x] T2.7 — stats tests - [x] T2.7 — stats tests
### Phase 3 — Per-job run policy ### Phase 3 — Per-job run policy
- [ ] T3.1 — `Job.OverlapPolicy` field - [x] T3.1 — `Job.OverlapPolicy` field
- [ ] T3.2 — effective-policy dispatch + inherit - [x] T3.2 — effective-policy dispatch + inherit
- [ ] T3.3 — dialog select + settings/format wording - [x] T3.3 — dialog select + settings/format wording
- [ ] T3.4 — per-job override tests - [x] T3.4 — per-job override tests
### Phase 4 — Persist global pause state ### Phase 4 — Persist global pause state
- [ ] T4.1 — `Config.Paused` field - [ ] T4.1 — `Config.Paused` field
+5 -1
View File
@@ -152,13 +152,17 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
func (s *Service) SetGlobalPause(paused bool) error { func (s *Service) SetGlobalPause(paused bool) error {
s.mu.Lock() s.mu.Lock()
s.paused = paused s.paused = paused
s.store.Config.Paused = paused
now := time.Now() now := time.Now()
for index := range s.jobs { for index := range s.jobs {
job := &s.jobs[index] job := &s.jobs[index]
runtime := s.runtimeForLocked(job) runtime := s.runtimeForLocked(job)
s.refreshNextRunFromLocked(job, runtime, now) s.refreshNextRunFromLocked(job, runtime, now)
} }
err := s.store.SaveJobs(s.jobs) err := s.store.SaveConfig()
if err == nil {
err = s.store.SaveJobs(s.jobs)
}
s.mu.Unlock() s.mu.Unlock()
state, detail := "Resumed", "All job execution resumed" state, detail := "Resumed", "All job execution resumed"
+141
View File
@@ -286,6 +286,147 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
} }
} }
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
// "queue" policy queues a re-run even though the global default is "skip": the
// effective policy is resolved per job, so the job-level value wins.
func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicySkip, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicyQueue)},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// Re-due the running job and tick. Despite the global "skip", the job's own
// "queue" policy must mark it Pending.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].Pending
svc.mu.Unlock()
if !pending {
t.Fatal("per-job queue policy must mark the job Pending despite global skip")
}
// Releasing the first run lets executeRun start the deferred re-run.
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("re-run job = %d, want 1", id)
}
waitRecord(t, done)
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("runner called %d time(s), want 2 (original + queued re-run)", got)
}
}
// TestRunDuePerJobSkipOverridesGlobalQueue verifies the reverse override: a job
// carrying its own "skip" policy drops an overlapping run even though the global
// default is "queue".
func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicySkip)},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// Re-due the running job and tick. Despite the global "queue", the job's own
// "skip" policy must drop it without marking Pending.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].Pending
svc.mu.Unlock()
if pending {
t.Error("per-job skip policy must not mark the job Pending despite global queue")
}
close(release)
waitRecord(t, done)
expectNoEntry(t, entered)
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d time(s), want 1", got)
}
}
// TestRunDueEmptyOverlapInheritsGlobal verifies that a job with no own policy
// inherits the global default: with global "queue" and an empty Job.OverlapPolicy
// the job queues a re-run.
func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
if svc.jobs[0].OverlapPolicy != "" {
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
}
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].Pending
svc.mu.Unlock()
if !pending {
t.Fatal("empty per-job policy must inherit the global queue and mark Pending")
}
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("inherited-queue re-run job = %d, want 1", id)
}
waitRecord(t, done)
}
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual // TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
// run is refused while another job is running, and allowed once nothing is. // run is refused while another job is running, and allowed once nothing is.
func TestRunNowSequentialGuard(t *testing.T) { func TestRunNowSequentialGuard(t *testing.T) {
+1
View File
@@ -82,6 +82,7 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
schedules: make(map[int]domain.Schedule, len(jobs)), schedules: make(map[int]domain.Schedule, len(jobs)),
runJob: runner.RunJob, runJob: runner.RunJob,
ctx: context.Background(), ctx: context.Background(),
paused: store.Config.Paused,
} }
// Parse every schedule once, then compute each job's first next-run so the // Parse every schedule once, then compute each job's first next-run so the
// Service is ready to schedule the moment it exists — mirroring the old // Service is ready to schedule the moment it exists — mirroring the old
+1
View File
@@ -40,6 +40,7 @@ type Config struct {
NotifyOnFailure bool `json:"notify_on_failure,omitempty"` NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"` ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"` OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
Paused bool `json:"paused,omitempty"`
} }
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level // JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
+1
View File
@@ -33,6 +33,7 @@ type yamlConfig struct {
NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"` NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"`
ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"` ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"`
OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"` OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"`
Paused bool `yaml:"paused,omitempty"`
} }
type yamlJob struct { type yamlJob struct {