P4: schedule battery — generic cron Runner
A host-agnostic ticker (Tick = one pass; Loop = run on an interval until ctx done) that fires due jobs. Every dependency is wired by the host: - Due lists due jobs (skill.ListDueScheduled / persona.ListScheduledAgents), - Run executes one (run.Executor), - Mark stamps the next fire (store.MarkScheduledRun), - Next computes the cron next-fire (a cron lib / skill's parser). The battery owns NO cron grammar, so it never duplicates the parser. A job whose Run or Next errors is logged and left un-stamped (stays due, retries next tick) — one bad job can't stall the others; only a failing Due lister is pass-fatal. Tests: due jobs run + stamped, bad-cron job runs but isn't stamped, a failing Run doesn't stamp or stall siblings, Due error surfaces. Core imports ZERO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTickRunsDueAndStampsNext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
var ran []string
|
||||
marked := map[string]time.Time{}
|
||||
|
||||
r := &Runner{
|
||||
Now: func() time.Time { return now },
|
||||
Due: func(_ context.Context, _ time.Time) ([]Due, error) {
|
||||
return []Due{{ID: "a", Cron: "hourly"}, {ID: "b", Cron: "bad"}}, nil
|
||||
},
|
||||
Run: func(_ context.Context, id string) error { ran = append(ran, id); return nil },
|
||||
Mark: func(_ context.Context, id string, _, next time.Time) error { marked[id] = next; return nil },
|
||||
Next: func(cron string, after time.Time) (time.Time, error) {
|
||||
if cron == "bad" {
|
||||
return time.Time{}, errors.New("unparseable")
|
||||
}
|
||||
return after.Add(time.Hour), nil
|
||||
},
|
||||
}
|
||||
if err := r.Tick(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both ran; only the parseable one got a next stamp.
|
||||
if len(ran) != 2 {
|
||||
t.Errorf("ran = %v, want both", ran)
|
||||
}
|
||||
if marked["a"] != now.Add(time.Hour) {
|
||||
t.Errorf("a next = %v, want +1h", marked["a"])
|
||||
}
|
||||
if _, ok := marked["b"]; ok {
|
||||
t.Errorf("b should not be stamped (bad cron), got %v", marked["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickRunFailureDoesNotStampOrStall(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var ran []string
|
||||
marked := map[string]bool{}
|
||||
r := &Runner{
|
||||
Due: func(_ context.Context, _ time.Time) ([]Due, error) {
|
||||
return []Due{{ID: "x", Cron: "h"}, {ID: "y", Cron: "h"}}, nil
|
||||
},
|
||||
Run: func(_ context.Context, id string) error {
|
||||
ran = append(ran, id)
|
||||
if id == "x" {
|
||||
return errors.New("boom")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Mark: func(_ context.Context, id string, _, _ time.Time) error { marked[id] = true; return nil },
|
||||
Next: func(string, time.Time) (time.Time, error) { return time.Now(), nil },
|
||||
}
|
||||
if err := r.Tick(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ran) != 2 { // y still runs despite x failing
|
||||
t.Errorf("ran = %v, want both attempted", ran)
|
||||
}
|
||||
if marked["x"] { // failed job NOT stamped -> stays due, retries
|
||||
t.Error("failed job x should not be stamped")
|
||||
}
|
||||
if !marked["y"] {
|
||||
t.Error("y should be stamped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickDueErrorIsFatalToPass(t *testing.T) {
|
||||
r := &Runner{Due: func(context.Context, time.Time) ([]Due, error) { return nil, errors.New("store down") }}
|
||||
if err := r.Tick(context.Background()); err == nil {
|
||||
t.Error("Tick should surface the Due lister error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user