T3.4: Convert scheduler to drive app.Service; inject Clock

The scheduler no longer shares a *[]domain.Job with the GUI. It is now a
thin timing loop with an injected Clock that calls a tick callback; the
application service is the sole writer of job and runtime state.

- scheduler: add Clock interface + RealClock (clock.go); strip all job
  logic from scheduler.go (NewScheduler(clock, tick)); rewrite tests to
  cover the loop with a fake clock.
- app.Service: add RunDue(now) (pause + one-run-per-tick policy, records
  back through the service) and Start(Clock)/Stop() owning a cancelable
  run context; prime each job's first next-run at construction. Capture
  the run context under the lock for executeRun.
- gui: talk only to app.Service (no shared state) — Open() the service,
  keep a refreshed snapshot, route every mutation through the service,
  and react to changes via a single Subscribe listener.
- Tests: add RunDue (due/not-due/paused) and Start-drives-RunDue cases.

Verified with CGO + MSYS2 UCRT64: go vet ./... clean, go test -race
./... green (GUI included), full module builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-19 08:22:35 +03:00
parent d8ab9acf7e
commit a4c93a5122
8 changed files with 459 additions and 442 deletions
+40 -3
View File
@@ -1,6 +1,7 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
@@ -200,6 +201,40 @@ func (s *Service) RunNow(id int) error {
return err
}
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
// the given time. It is a no-op while globally paused. At most one job is started
// per call so scheduled shell commands in this single process do not overlap; a
// job already running is skipped. Run results are recorded back through the
// Service, so the Service stays the sole writer of job and runtime state. The
// time is supplied by the scheduler's clock, which lets tests drive
// due-evaluation deterministically.
func (s *Service) RunDue(now time.Time) {
s.mu.Lock()
var startedID int
if !s.paused {
for index := range s.jobs {
job := &s.jobs[index]
runtime := s.runtimeForLocked(job)
if !job.Enabled || runtime.NextDue.IsZero() || now.Before(runtime.NextDue) {
continue
}
if runtime.LastState == "Running" {
continue
}
// Async save errors cannot be returned to a caller here; surfacing them
// is deferred to T5.1 with the rest of the swallowed saves.
_ = s.startRunLocked(job, runtime, "Schedule")
startedID = job.ID
break
}
}
s.mu.Unlock()
if startedID != 0 {
s.emit(JobChanged{JobID: startedID})
}
}
// UpdateSettings validates and persists a new application configuration. The
// loaded jobs are re-saved because the jobs directory may have changed, and log
// cleanup runs so a tightened retention policy takes effect immediately.
@@ -239,14 +274,16 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
runtime.Output = runningOutput(jobCopy, trigger, time.Now())
runtime.NextDue = time.Time{}
err := s.store.SaveJobs(s.jobs)
go s.executeRun(jobCopy, trigger)
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
// from under the goroutine after we release mu.
go s.executeRun(s.ctx, jobCopy, trigger)
return err
}
// executeRun runs the job off the lock, then records the result back through the
// Service under the lock and announces it. It runs on its own goroutine.
func (s *Service) executeRun(jobCopy domain.Job, trigger string) {
record := s.runJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string) {
record := s.runJob(ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
s.mu.Lock()
if current := s.findByIDLocked(jobCopy.ID); current != nil {