diff --git a/CHANGELOG.md b/CHANGELOG.md index 59afa7d..cb536f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to nightshift are documented in this file. +## [v0.3.4] - 2026-02-28 + +### Features +- **Configurable agent timeouts** — add `--timeout` to `nightshift run` and `nightshift daemon`, with daemon re-exec forwarding the flag (#27) +- **Expanded PII scanner guidance** — add detailed instructions for detecting hardcoded PII, leaked env files, unsafe storage, and related exposure patterns in the built-in task (#34) + +### Fixes +- **Timeout handling and diagnostics** — preserve partial output on timeout, terminate full process groups, and surface partial logs from failed plan/implement/review steps (#33) +- **Copilot CLI integration** — improve binary resolution, permission gating, and CLI flag handling for Copilot runs (#39) +- **Provider config YAML serialization** — write provider settings with the correct YAML keys during setup (#43) +- **Configured run limits and budget fallback** — honor `schedule.max_projects` and `schedule.max_tasks`, improve budget calibration at day and week boundaries, and preserve Codex fallback permissions in headless runs (#42) + +### Other +- **Task reference docs** — add a comprehensive reference page for all 59 built-in tasks and refresh related task documentation (#30) +- **Docs cleanup** — remove auto-generated implementation docs from the repository (#40) +- **Low-risk cleanup** — resolve Copilot helper lint warnings and replace `WriteString(fmt.Sprintf(...))` patterns with `fmt.Fprintf` in reporting and setup code (#38, #41) + ## [v0.3.3] - 2026-02-19 ### Features diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 278ca71..470717e 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -473,11 +473,14 @@ func (s *Scheduler) ScheduleInterval(d time.Duration, job func()) error { return nil } -// Schedule adds a one-time job to run at the specified time. +// Schedule adds a one-time job to run at or after the specified time. +// The job fires exactly once on the first scheduler tick at or after `at`; +// subsequent ticks are no-ops. func (s *Scheduler) Schedule(at time.Time, job func()) { + var once sync.Once s.AddJob(func(ctx context.Context) error { - if time.Now().After(at) { - job() + if !time.Now().Before(at) { + once.Do(job) } return nil }) diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index e28a714..a3cf530 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -579,3 +579,42 @@ func TestScheduleInterval_Legacy(t *testing.T) { t.Errorf("len(jobs) = %d, want 1", len(s.jobs)) } } + +func TestSchedule_RunsExactlyOnce(t *testing.T) { + s := New() + ctx := context.Background() + + var count int32 + // Schedule for a time already in the past so the job is eligible. + s.Schedule(time.Now().Add(-time.Minute), func() { + atomic.AddInt32(&count, 1) + }) + + // Simulate multiple scheduler ticks; the job must only run once. + for i := 0; i < 5; i++ { + s.runJobs(ctx) + } + + if got := atomic.LoadInt32(&count); got != 1 { + t.Errorf("Schedule one-time job ran %d times, want 1", got) + } +} + +func TestSchedule_NotBeforeTargetTime(t *testing.T) { + s := New() + ctx := context.Background() + + var count int32 + // Schedule for a time in the future; the job must not fire yet. + s.Schedule(time.Now().Add(time.Hour), func() { + atomic.AddInt32(&count, 1) + }) + + for i := 0; i < 3; i++ { + s.runJobs(ctx) + } + + if got := atomic.LoadInt32(&count); got != 0 { + t.Errorf("Schedule job ran %d times before target, want 0", got) + } +}