Merge pull request #13747 from esengine/a5/schedule-tick

feat(schedule): a tick that runs what is due, and the schedule commands
This commit is contained in:
YHH authored and GitHub committed 2026-10-18 00:55:49 -07:00
commit ed1c127d65
12 files changed
+1155 -7

No files matched your search

+35
View File
@@ -1758,6 +1758,41 @@ read-only: an explicit old path can be recovered as an ordinary Goal, but new
runs never create or update those directories. Deprecated budget flags are
accepted for compatibility but are hidden from help and completion.
## Scheduled runs
A schedule makes Reasonix run a read-only task for you at a time you chose and leave the result waiting. It is
the command line only for now; Studio gets a list later.
```bash
reasonix schedule add --every 2h --prompt "Summarize what changed in the open TODOs" --workspace ~/src/app
reasonix schedule list
reasonix schedule pause <id> # pause-all, resume, resume-all, renew and rm work the same way
reasonix schedule tick # claim and run what is due now, then exit
```
- **A person confirms it.** `add`, `resume`, `renew`, `rm` and `resume-all` need a terminal. `add` shows the whole
plan (prompt, folder, model, budget, expiry) and asks you to type back its short id; `list` shows who created
each one. A command run by a tool, a pipe or a scheduler is refused.
- The host marks every subprocess it starts for the agent with `REASONIX_AGENT_CHILD=1`, and these commands
refuse when it is set, so an agent that opens its own pty is stopped too.
- That is a guard against accidents and a trick, not a boundary: a child can clear its own environment, and
only the sandbox confines what an agent can do.
- **Nothing runs by itself yet.** Call `reasonix schedule tick` from the operating system's scheduler (cron,
launchd, Task Scheduler). Every tick goes through the store's own budget and expiry checks, so a scheduler
that only calls `tick` cannot bypass them. An in-kernel ticker is a later change.
- **Read-only.** A run reads files in its folder and writes nothing. Anything that needs your approval is parked
for you and never answered on your behalf.
- **Ceilings.** `[schedule]` in your user config can change them; a repository's `reasonix.toml` can only
tighten them.
- One run at a time, and by default at least 60 minutes between runs (never under 10).
- Per run: 250,000 tokens, 40 steps and 15 minutes.
- 4 runs a day for one schedule and 6 for all of them, and a lifetime budget per schedule.
- A 30 day expiry that only you renew.
- **Late and missed runs.** Only the latest slot counts. It still runs if it is at most 15 minutes late (never
more than a quarter of the interval); otherwise it is recorded as skipped and not made up.
- **The model is pinned** when you create the schedule, from your own configuration. If it later becomes
unavailable the run fails and says so.
## @ references
Embed `@` references in a message and Reasonix resolves them before sending, as
+17 -2
View File
@@ -139,9 +139,24 @@ func filterRegisteredCredentialEnv(env []string) []string {
// filter_subprocess_env, preserving existing gh/git/npm workflows by default.
func ProcessEnv() []string {
if !filterSubprocessEnvEnabled.Load() {
return filterRegisteredCredentialEnv(os.Environ())
return markAgentChild(filterRegisteredCredentialEnv(os.Environ()))
}
return FilterEnv(os.Environ())
return markAgentChild(FilterEnv(os.Environ()))
}
// AgentChildEnv marks every subprocess the host starts for the agent. Commands
// that need a person at a terminal refuse when it is set. A child can unset it,
// so it is friction against accidents and a pty trick, not a boundary.
const AgentChildEnv = "REASONIX_AGENT_CHILD"
func markAgentChild(env []string) []string {
out := env[:0]
for _, item := range env {
if key, _, _ := strings.Cut(item, "="); key != AgentChildEnv {
out = append(out, item)
}
}
return append(out, AgentChildEnv+"=1")
}
// Redact masks credential-like values for explicit diagnostic, export, and
+17
View File
@@ -305,3 +305,20 @@ func TestProcessEnvAlwaysFiltersRegisteredCredentialKeys(t *testing.T) {
t.Fatalf("ordinary env was removed with opt-in filtering off:\n%s", joined)
}
}
func TestProcessEnvMarksSubprocessesAsAgentChildren(t *testing.T) {
t.Setenv(AgentChildEnv, "0")
for _, filtered := range []bool{false, true} {
SetFilterSubprocessEnv(filtered)
var marks []string
for _, item := range ProcessEnv() {
if strings.HasPrefix(item, AgentChildEnv+"=") {
marks = append(marks, item)
}
}
if len(marks) != 1 || marks[0] != AgentChildEnv+"=1" {
t.Fatalf("filter=%v: marker = %v, want exactly one %s=1", filtered, marks, AgentChildEnv)
}
}
SetFilterSubprocessEnv(false)
}
+3 -4
View File
@@ -25,12 +25,11 @@ func unknownCommand(cmd string, rest []string) int {
return 2
}
// scheduleCommand dispatches `reasonix schedule`. Only the supervisor's own
// child entry exists here; it is not a command a person types.
// scheduleCommand dispatches `reasonix schedule`. exec is the supervisor's own
// child entry, not a command a person types; the rest manage and tick schedules.
func scheduleCommand(args []string) int {
if len(args) > 0 && args[0] == "exec" {
return scheduleExec(args[1:])
}
fmt.Fprintln(os.Stderr, "usage: reasonix schedule exec <trigger-id> (started by the scheduler, not by hand)")
return 2
return scheduleManage(args, scheduleOS())
}
+386
View File
@@ -0,0 +1,386 @@
package cli
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"golang.org/x/term"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/config"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/runtime/scheduler"
"reasonix/internal/state/schedule"
)
// Exit statuses of the person-facing schedule commands.
const (
scheduleExitOK = 0
scheduleExitFailed = 1
scheduleExitUsage = 2
scheduleExitNotHuman = 77
)
const scheduleUsage = `usage: reasonix schedule <command>
add --every 2h|--at <time> --prompt <text> [--workspace dir] [--label name] [--model provider/model]
list
pause <id> | resume <id> | renew <id> | rm <id>
pause-all | resume-all
tick claim and run what is due now, then exit (for an OS scheduler to call)
Creating, resuming and deleting need a terminal: a person confirms them by typing back the plan id.`
// scheduleSupervisorForCLI builds the runner a tick hands claimed runs to.
var scheduleSupervisorForCLI = func(store *schedule.Store, p schedule.Policy) scheduler.Runner {
return &schedrun.Supervisor{Store: store, Policy: p}
}
type scheduleIO struct {
in io.Reader
out io.Writer
err io.Writer
human bool
// agentChild is set when the host started this process for an agent tool.
agentChild bool
now func() time.Time
}
func scheduleOS() scheduleIO {
human := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
return scheduleIO{in: os.Stdin, out: os.Stdout, err: os.Stderr, human: human, agentChild: os.Getenv(secrets.AgentChildEnv) == "1", now: time.Now}
}
func scheduleManage(args []string, sio scheduleIO) int {
if len(args) == 0 {
fmt.Fprintln(sio.err, scheduleUsage)
return scheduleExitUsage
}
store, policy, err := openScheduleStore(sio.now)
if err != nil {
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitFailed
}
cmd, rest := args[0], args[1:]
switch cmd {
case "list":
return scheduleList(store, sio)
case "tick":
return scheduleTick(store, policy, sio)
case "pause-all":
return scheduleSwitch(store, sio, func(ctx context.Context) error { return store.SetPausedAll(ctx, true) }, "all schedules paused")
case "resume-all":
if !requireHuman(sio, "resume-all") {
return scheduleExitNotHuman
}
return scheduleSwitch(store, sio, func(ctx context.Context) error { return store.SetPausedAll(ctx, false) }, "schedules resumed")
case "pause", "resume", "renew", "rm":
return scheduleOne(cmd, rest, store, policy, sio)
case "add":
return scheduleAdd(rest, store, policy, sio)
}
fmt.Fprintln(sio.err, scheduleUsage)
return scheduleExitUsage
}
func openScheduleStore(now func() time.Time) (*schedule.Store, schedule.Policy, error) {
roots := config.RootsForHome("")
dir := roots.ScheduleDir()
if dir == "" {
return nil, schedule.Policy{}, errors.New("the state directory is unknown")
}
store, err := schedule.Open(dir)
if err != nil {
return nil, schedule.Policy{}, err
}
user, err := schedule.LoadOverrides(roots.UserConfigLoadPath())
if err != nil {
return nil, schedule.Policy{}, err
}
policy, _, err := schedule.Resolve(user, schedule.Overrides{})
if err != nil {
return nil, schedule.Policy{}, err
}
if now != nil {
store = store.WithClock(now)
}
return store, policy, nil
}
// requireHuman refuses what only a person at a terminal may do. A command run by
// a tool, a pipe or a scheduler has no terminal, so it cannot create, resume or
// delete a schedule. A terminal an agent tool opened itself is refused by the
// marker the host puts on its subprocesses; that is a guard against a pty trick,
// not a boundary, because a child can clear its own environment.
func requireHuman(sio scheduleIO, what string) bool {
if sio.agentChild {
fmt.Fprintf(sio.err, "schedule %s is refused: this command was started by an agent tool (%s is set)\n", what, secrets.AgentChildEnv)
return false
}
if sio.human {
return true
}
fmt.Fprintf(sio.err, "schedule %s needs a person at a terminal to confirm it\n", what)
return false
}
func scheduleSwitch(_ *schedule.Store, sio scheduleIO, do func(context.Context) error, done string) int {
if err := do(context.Background()); err != nil {
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitFailed
}
fmt.Fprintln(sio.out, done)
return scheduleExitOK
}
func scheduleOne(cmd string, args []string, store *schedule.Store, policy schedule.Policy, sio scheduleIO) int {
if len(args) != 1 {
fmt.Fprintln(sio.err, scheduleUsage)
return scheduleExitUsage
}
if cmd != "pause" && !requireHuman(sio, cmd) {
return scheduleExitNotHuman
}
ctx := context.Background()
var err error
switch cmd {
case "pause":
err = store.Pause(ctx, args[0])
case "resume":
err = store.Resume(ctx, args[0])
case "rm":
err = store.Delete(ctx, args[0])
case "renew":
err = store.Renew(ctx, policy, args[0])
}
if err != nil {
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitFailed
}
fmt.Fprintf(sio.out, "%s %s\n", cmd, args[0])
return scheduleExitOK
}
func scheduleList(store *schedule.Store, sio scheduleIO) int {
m, _, err := store.Snapshot(context.Background())
if err != nil {
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitFailed
}
if m.PausedAll {
fmt.Fprintln(sio.out, "ALL SCHEDULES ARE PAUSED")
}
if len(m.Schedules) == 0 {
fmt.Fprintln(sio.out, "no schedules")
}
for _, s := range m.Schedules {
fmt.Fprintf(sio.out, "%s %-9s %s %s spent %d of %d tokens expires %s created by %s\n", s.ID, s.Status, describeTrigger(s.Trigger), displayLabel(s),
s.SpentLifetimeTokens, s.Budget.LifetimeTokens, s.ExpiresAt.Local().Format("2006-01-02"), createdBy(s))
}
runs := m.Runs
if len(runs) > 10 {
runs = runs[len(runs)-10:]
}
for _, r := range runs {
fmt.Fprintf(sio.out, " run %s %s %d tokens %s\n", r.TriggerID, r.State, r.Charged, r.SkipReason)
}
return scheduleExitOK
}
func createdBy(s schedule.Schedule) string {
if s.Confirmed.Via == "" {
return "unknown"
}
return s.Confirmed.Via
}
func displayLabel(s schedule.Schedule) string {
if s.Label != "" {
return fmt.Sprintf("%q", s.Label)
}
return filepath.Base(s.Target.Workspace)
}
func describeTrigger(t schedule.Trigger) string {
if t.Kind == schedule.TriggerAt {
return "at " + t.At.Local().Format("2006-01-02 15:04")
}
return "every " + (time.Duration(t.EverySec) * time.Second).String()
}
func scheduleTick(store *schedule.Store, p schedule.Policy, sio scheduleIO) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
res, err := (&scheduler.Scheduler{Store: store, Policy: p, Runner: scheduleSupervisorForCLI(store, p), Now: sio.now}).Tick(ctx)
if res.Paused {
fmt.Fprintln(sio.out, "all schedules are paused; nothing was started")
}
for _, id := range res.Reaped {
fmt.Fprintln(sio.out, "reaped", id)
}
for _, s := range res.Skipped {
fmt.Fprintf(sio.out, "skipped %s at %s: %s\n", s.ScheduleID, s.Slot.Local().Format(time.RFC3339), s.Reason)
}
for _, r := range res.Refused {
fmt.Fprintf(sio.out, "refused %s: %v\n", r.ScheduleID, r.Err)
}
for _, r := range res.Reports {
fmt.Fprintf(sio.out, "ran %s: %s %s\n", r.TriggerID, r.State, r.Code)
}
if err != nil {
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitFailed
}
return scheduleExitOK
}
type addFlags struct {
every, at, prompt, workspace, label, model string
}
func scheduleAdd(args []string, store *schedule.Store, p schedule.Policy, sio scheduleIO) int {
fs := flag.NewFlagSet("schedule add", flag.ContinueOnError)
fs.SetOutput(sio.err)
var f addFlags
fs.StringVar(&f.every, "every", "", "repeat at this interval, for example 2h")
fs.StringVar(&f.at, "at", "", "run once at this time (RFC 3339, or 2006-01-02 15:04 local)")
fs.StringVar(&f.prompt, "prompt", "", "what the run should do")
fs.StringVar(&f.workspace, "workspace", "", "the folder it works in (default: the current directory)")
fs.StringVar(&f.label, "label", "", "an optional name")
fs.StringVar(&f.model, "model", "", "provider/model (default: your default model)")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return scheduleExitUsage
}
if !requireHuman(sio, "add") {
return scheduleExitNotHuman
}
req, err := f.request()
if err != nil {
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitUsage
}
budget := p.DefaultBudget()
plan := preview(req, budget, p, sio.now())
code := planCode(plan)
fmt.Fprint(sio.out, plan)
fmt.Fprintf(sio.out, "Type %s to create this schedule, anything else cancels: ", code)
line, _ := bufio.NewReader(sio.in).ReadString('\n')
if strings.TrimSpace(line) != code {
fmt.Fprintln(sio.out, "not created")
return scheduleExitOK
}
sc, err := store.Create(context.Background(), p, req)
if err != nil {
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitFailed
}
fmt.Fprintf(sio.out, "created %s\n", sc.ID)
return scheduleExitOK
}
func (f addFlags) request() (schedule.CreateRequest, error) {
req := schedule.CreateRequest{Label: strings.TrimSpace(f.label), Prompt: strings.TrimSpace(f.prompt), ConfirmedBy: schedule.ConfirmedByHuman, ConfirmedVia: schedule.ConfirmedViaTerminal}
switch {
case (f.every == "") == (f.at == ""):
return req, errors.New("give exactly one of --every and --at")
case f.every != "":
d, err := time.ParseDuration(f.every)
if err != nil || d <= 0 {
return req, fmt.Errorf("--every %q is not a duration such as 90m or 2h", f.every)
}
req.Trigger = schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: int64(d / time.Second)}
default:
at, err := parseAt(f.at)
if err != nil {
return req, err
}
req.Trigger = schedule.Trigger{Kind: schedule.TriggerAt, At: at}
}
ws := f.workspace
if ws == "" {
wd, err := os.Getwd()
if err != nil {
return req, err
}
ws = wd
}
abs, err := filepath.Abs(ws)
if err != nil {
return req, err
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
return req, fmt.Errorf("--workspace %q is not a folder", ws)
}
req.Target = schedule.Target{Workspace: abs}
model, err := pinModel(f.model, abs)
if err != nil {
return req, err
}
req.Model = model
return req, nil
}
func parseAt(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
if t, err := time.ParseInLocation("2006-01-02 15:04", s, time.Local); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("--at %q is not a time (RFC 3339, or 2006-01-02 15:04 in local time)", s)
}
// pinModel fixes the model at creation from the user's own configuration, so a
// repository cannot change which model, or whose key, a confirmed run uses.
func pinModel(ref, workspace string) (schedule.Model, error) {
cfg, err := config.RootsForHome("").LoadUserScopeForRoot(workspace)
if err != nil {
return schedule.Model{}, err
}
if ref == "" {
ref = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(ref)
if !ok || entry == nil {
return schedule.Model{}, fmt.Errorf("model %q is not a configured model; give one with --model", ref)
}
return schedule.Model{Provider: entry.Name, Model: entry.Model}, nil
}
// preview is what a person confirms: when it first runs, what it may do, and the
// most it can spend, in the policy's own numbers.
// planCode is the short id a person types back: it names the exact plan shown,
// so a confirmation typed for one plan cannot be replayed for another.
func planCode(plan string) string {
sum := sha256.Sum256([]byte(plan))
return hex.EncodeToString(sum[:3])
}
func preview(req schedule.CreateRequest, b schedule.Budget, p schedule.Policy, now time.Time) string {
var sb strings.Builder
fmt.Fprintf(&sb, "Schedule %s\n", describeTrigger(req.Trigger))
if req.Trigger.Kind == schedule.TriggerEvery {
fmt.Fprintf(&sb, " first run: %s\n", now.Add(time.Duration(req.Trigger.EverySec)*time.Second).Local().Format("2006-01-02 15:04"))
}
fmt.Fprintf(&sb, " folder: %s (read-only: it can read files here and write nothing)\n", req.Target.Workspace)
fmt.Fprintf(&sb, " model: %s/%s\n", req.Model.Provider, req.Model.Model)
fmt.Fprintf(&sb, " prompt: %s\n", req.Prompt)
fmt.Fprintf(&sb, " each run: up to %d tokens, %d steps, %d s\n", b.PerRunTokens, b.PerRunSteps, b.PerRunWallSec)
perDay := min(b.PerRunTokens*b.MaxRunsPerDay, p.GlobalTokensDay)
fmt.Fprintf(&sb, " at most: %d tokens per day, %d over its lifetime; it ends on its own after %d days\n", perDay, b.LifetimeTokens, p.ExpiryDays)
sb.WriteString(" Anything that needs your approval is parked for you, never answered for you.\n")
return sb.String()
}
@@ -0,0 +1,269 @@
package cli
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/runtime/scheduler"
"reasonix/internal/state/schedule"
)
type schedWorld struct {
t *testing.T
ws string
now time.Time
out bytes.Buffer
err bytes.Buffer
input string
human bool
// agentChild simulates a command started by an agent tool.
agentChild bool
}
func newSchedWorld(t *testing.T) *schedWorld {
t.Helper()
isolateCLIConfigHome(t)
home := os.Getenv("HOME")
cfg := `default_model = "local/fake-model"
[[providers]]
name = "local"
kind = "acp-test-provider"
base_url = "http://example.invalid"
model = "fake-model"
api_key_env = "REASONIX_TEST_KEY"
`
t.Setenv("REASONIX_TEST_KEY", "k")
if err := os.MkdirAll(config.RootsForHome("").Home(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.RootsForHome("").UserConfigLoadPath(), []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
_ = home
return &schedWorld{t: t, ws: t.TempDir(), now: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC), human: true}
}
func (w *schedWorld) run(args ...string) int {
w.t.Helper()
w.out.Reset()
w.err.Reset()
return scheduleManage(args, scheduleIO{in: w.stdin(), out: &w.out, err: &w.err, human: w.human, agentChild: w.agentChild, now: func() time.Time { return w.now }})
}
// stdin answers "CONFIRM" with whatever plan code the command printed, as a
// person reading the prompt would; any other input is passed through.
func (w *schedWorld) stdin() io.Reader {
if w.input != "CONFIRM" {
return strings.NewReader(w.input)
}
return readerFunc(func(p []byte) (int, error) {
m := regexp.MustCompile(`Type ([0-9a-f]{6}) to create`).FindStringSubmatch(w.out.String())
if m == nil {
return 0, io.EOF
}
return copy(p, m[1]+"\n"), io.EOF
})
}
type readerFunc func([]byte) (int, error)
func (f readerFunc) Read(p []byte) (int, error) { return f(p) }
func (w *schedWorld) store() *schedule.Store {
w.t.Helper()
st, _, err := openScheduleStore(func() time.Time { return w.now })
if err != nil {
w.t.Fatal(err)
}
return st
}
func (w *schedWorld) schedules() []schedule.Schedule {
m, _, err := w.store().Snapshot(context.Background())
if err != nil {
w.t.Fatal(err)
}
return m.Schedules
}
func (w *schedWorld) add() schedule.Schedule {
w.t.Helper()
w.input = "CONFIRM"
if code := w.run("add", "--every", "2h", "--prompt", "summarize TODOs", "--workspace", w.ws); code != 0 {
w.t.Fatalf("add = %d: %s %s", code, w.out.String(), w.err.String())
}
got := w.schedules()
if len(got) != 1 {
w.t.Fatalf("schedules = %d", len(got))
}
return got[0]
}
func TestScheduleAddShowsWhatIsConfirmedAndCreatesOnlyOnYes(t *testing.T) {
w := newSchedWorld(t)
w.input = "n\n"
if code := w.run("add", "--every", "2h", "--prompt", "summarize TODOs", "--workspace", w.ws, "--label", "morning"); code != 0 {
t.Fatalf("exit %d: %s", code, w.err.String())
}
realWS, err := filepath.EvalSymlinks(w.ws)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"every 2h0m0s", "first run: 2026-", realWS, "local/fake-model", "summarize TODOs", "up to 250000 tokens", "at most: 1000000 tokens per day", "parked for you"} {
if !strings.Contains(w.out.String(), want) {
t.Fatalf("preview lacks %q:\n%s", want, w.out.String())
}
}
if len(w.schedules()) != 0 {
t.Fatal("a declined confirmation still created a schedule")
}
w.input = "y\n"
if code := w.run("add", "--every", "2h", "--prompt", "summarize TODOs", "--workspace", w.ws); code != 0 || len(w.schedules()) != 0 {
t.Fatalf("typing y instead of the plan id created something: %d %d", code, len(w.schedules()))
}
sc := w.add()
if sc.Confirmed.Via != schedule.ConfirmedViaTerminal {
t.Fatalf("via = %q", sc.Confirmed.Via)
}
w.run("list")
if !strings.Contains(w.out.String(), "created by terminal") {
t.Fatalf("list does not say who created it:\n%s", w.out.String())
}
if sc.Confirmed.By != schedule.ConfirmedByHuman || sc.Model.Provider != "local" || sc.Model.Model != "fake-model" || sc.Trigger.EverySec != 7200 {
t.Fatalf("schedule = %+v", sc)
}
}
// A command run by a tool, a pipe or a scheduler has no terminal: it cannot
// create, resume, renew or delete anything, whatever it types.
func TestSchedulesNeedAPersonAtATerminalToCreateResumeOrDelete(t *testing.T) {
w := newSchedWorld(t)
sc := w.add()
w.human = false
w.input = "y\n"
for _, args := range [][]string{
{"add", "--every", "2h", "--prompt", "x", "--workspace", w.ws},
{"resume", sc.ID}, {"renew", sc.ID}, {"rm", sc.ID}, {"resume-all"},
} {
if code := w.run(args...); code != scheduleExitNotHuman {
t.Fatalf("%v = %d, want %d", args, code, scheduleExitNotHuman)
}
}
if got := w.schedules(); len(got) != 1 || got[0].Status != schedule.StatusActive {
t.Fatalf("a refused command changed the store: %+v", got)
}
if code := w.run("pause", sc.ID); code != 0 {
t.Fatalf("pausing is protective and needs no terminal: %d %s", code, w.err.String())
}
if code := w.run("pause-all"); code != 0 {
t.Fatalf("pause-all = %d", code)
}
}
// A terminal an agent tool opened for itself passes the terminal check; the
// marker the host puts on its subprocesses is what refuses it.
func TestSchedulesRefuseACommandStartedByAnAgentTool(t *testing.T) {
w := newSchedWorld(t)
sc := w.add()
w.agentChild = true
w.input = "CONFIRM"
for _, args := range [][]string{
{"add", "--every", "2h", "--prompt", "x", "--workspace", w.ws},
{"resume", sc.ID}, {"renew", sc.ID}, {"rm", sc.ID}, {"resume-all"},
} {
if code := w.run(args...); code != scheduleExitNotHuman {
t.Fatalf("%v = %d, want %d", args, code, scheduleExitNotHuman)
}
if !strings.Contains(w.err.String(), "started by an agent tool") {
t.Fatalf("%v gave no reason: %q", args, w.err.String())
}
}
if got := w.schedules(); len(got) != 1 {
t.Fatalf("a refused command changed the store: %+v", got)
}
}
func TestScheduleAddRefusesWhatCannotBeConfirmed(t *testing.T) {
w := newSchedWorld(t)
w.input = "CONFIRM"
for name, args := range map[string][]string{
"both triggers": {"add", "--every", "2h", "--at", "2030-01-01 09:00", "--prompt", "x", "--workspace", w.ws},
"no trigger": {"add", "--prompt", "x", "--workspace", w.ws},
"a bad interval": {"add", "--every", "soon", "--prompt", "x", "--workspace", w.ws},
"no folder": {"add", "--every", "2h", "--prompt", "x", "--workspace", filepath.Join(w.ws, "nope")},
"unknown model": {"add", "--every", "2h", "--prompt", "x", "--workspace", w.ws, "--model", "nobody/none"},
} {
if code := w.run(args...); code == 0 {
t.Errorf("%s: accepted", name)
}
}
if code := w.run("add", "--every", "1m", "--prompt", "x", "--workspace", w.ws); code != scheduleExitFailed || !strings.Contains(w.err.String(), "below the minimum") {
t.Fatalf("an interval under the floor = %d %s", code, w.err.String())
}
if len(w.schedules()) != 0 {
t.Fatal("a refused add left a schedule")
}
}
func TestScheduleListShowsSchedulesRunsAndTheGlobalPause(t *testing.T) {
w := newSchedWorld(t)
sc := w.add()
w.run("pause-all")
if code := w.run("list"); code != 0 {
t.Fatalf("list = %d", code)
}
for _, want := range []string{"ALL SCHEDULES ARE PAUSED", sc.ID, "active", "every 2h0m0s", "spent 0 of 3000000 tokens"} {
if !strings.Contains(w.out.String(), want) {
t.Fatalf("list lacks %q:\n%s", want, w.out.String())
}
}
}
type settlingRunner struct{ st *schedule.Store }
func (r settlingRunner) Run(ctx context.Context, id string) (schedrun.Report, error) {
return schedrun.Report{TriggerID: id, State: schedule.RunSucceeded}, r.st.Finish(ctx, schedule.DefaultPolicy(), id, schedule.Outcome{State: schedule.RunSucceeded, Observed: 10, Clean: true})
}
func TestScheduleTickClaimsWhatIsDueRunsItAndSaysSo(t *testing.T) {
w := newSchedWorld(t)
sc := w.add()
prev := scheduleSupervisorForCLI
t.Cleanup(func() { scheduleSupervisorForCLI = prev })
scheduleSupervisorForCLI = func(st *schedule.Store, _ schedule.Policy) scheduler.Runner { return settlingRunner{st} }
if code := w.run("tick"); code != 0 || strings.Contains(w.out.String(), "ran ") {
t.Fatalf("tick before the slot = %d %s", code, w.out.String())
}
w.now = w.now.Add(2*time.Hour + time.Minute)
if code := w.run("tick"); code != 0 || !strings.Contains(w.out.String(), "ran "+sc.ID+"/") || !strings.Contains(w.out.String(), "succeeded") {
t.Fatalf("tick at the slot = %d %s %s", code, w.out.String(), w.err.String())
}
if code := w.run("tick"); code != 0 || strings.Contains(w.out.String(), "ran ") {
t.Fatalf("the same slot ran twice: %s", w.out.String())
}
w.run("pause-all")
w.now = w.now.Add(2 * time.Hour)
if code := w.run("tick"); code != 0 || !strings.Contains(w.out.String(), "paused") {
t.Fatalf("a paused tick said %q", w.out.String())
}
}
func TestScheduleUsageErrors(t *testing.T) {
w := newSchedWorld(t)
for _, args := range [][]string{{}, {"nonsense"}, {"pause"}, {"rm", "a", "b"}} {
if code := w.run(args...); code != scheduleExitUsage {
t.Errorf("%v = %d", args, code)
}
}
}
+11
View File
@@ -0,0 +1,11 @@
// Package scheduler decides which scheduled runs start now and hands each to a
// supervisor. It is the one place a tick is judged, so the in-kernel ticker and
// `reasonix schedule tick` cannot differ: the store admits or refuses every claim
// (budgets, expiry, the global pause, one run at a time), and this package only
// asks it, in order.
//
// Only the latest slot of a schedule is considered. A slot that is still within
// its grace is claimed; one that is past it is recorded as skipped, so a laptop
// opened after a week yields at most one late run per schedule and only while it
// is still timely. A run that was claimed is run to its end before Tick returns.
package scheduler
+134
View File
@@ -0,0 +1,134 @@
package scheduler
import (
"context"
"errors"
"time"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/state/schedule"
)
// DefaultLateGrace is how late a slot may still start. It is never more than a
// quarter of the schedule's interval.
const DefaultLateGrace = 15 * time.Minute
// Runner runs a claimed trigger to its end and settles it. *schedrun.Supervisor
// is the production one.
type Runner interface {
Run(ctx context.Context, triggerID string) (schedrun.Report, error)
}
// Scheduler is one driver of the store.
type Scheduler struct {
Store *schedule.Store
Policy schedule.Policy
Runner Runner
// Now is the clock; nil is the store's own wall clock.
Now func() time.Time
}
// Skipped is a slot that was deliberately not run, with the typed reason.
type Skipped struct {
ScheduleID string
Slot time.Time
Reason schedule.SkipReason
}
// Refused is a slot the store would not admit, by the sentinel it answered with.
type Refused struct {
ScheduleID string
Err error
}
// Result is what one tick did.
type Result struct {
Reaped []string
Skipped []Skipped
Refused []Refused
Reports []schedrun.Report
// Paused says the global switch was on and nothing was claimed.
Paused bool
}
func (s *Scheduler) now() time.Time {
if s.Now != nil {
return s.Now().UTC()
}
return time.Now().UTC()
}
// Tick reaps runs whose executor is gone, claims at most one due slot, runs it
// and returns. It never starts a second run while one is in flight: the store
// refuses it, and the slot waits for a later tick or is skipped once past grace.
func (s *Scheduler) Tick(ctx context.Context) (Result, error) {
var out Result
reaped, err := s.Store.ReapDead(ctx, s.Policy)
out.Reaped = reaped
if err != nil {
return out, err
}
m, _, err := s.Store.Snapshot(ctx)
if err != nil {
return out, err
}
if m.PausedAll {
out.Paused = true
return out, nil
}
now := s.now()
for _, sc := range m.Schedules {
if sc.Status != schedule.StatusActive {
continue
}
slot, ok := sc.Trigger.LatestSlot(now)
if !ok || hasRun(m, schedule.TriggerID(sc.ID, slot)) {
continue
}
if now.Sub(slot) > graceFor(sc) {
s.skip(ctx, &out, sc, slot, schedule.SkipMissed)
continue
}
run, err := s.Store.Claim(ctx, s.Policy, sc.ID, slot)
switch {
case err == nil:
rep, runErr := s.Runner.Run(ctx, run.TriggerID)
out.Reports = append(out.Reports, rep)
return out, runErr
case errors.Is(err, schedule.ErrConcurrency), errors.Is(err, schedule.ErrAlreadyClaimed),
errors.Is(err, schedule.ErrSlotStale), errors.Is(err, schedule.ErrNotDue):
// Another run is in flight, or someone else has this slot.
case errors.Is(err, schedule.ErrBudget):
s.skip(ctx, &out, sc, slot, schedule.SkipBudgetWindow)
default:
out.Refused = append(out.Refused, Refused{ScheduleID: sc.ID, Err: err})
}
}
return out, nil
}
func (s *Scheduler) skip(ctx context.Context, out *Result, sc schedule.Schedule, slot time.Time, why schedule.SkipReason) {
if err := s.Store.RecordSkip(ctx, sc.ID, slot, why); err == nil {
out.Skipped = append(out.Skipped, Skipped{ScheduleID: sc.ID, Slot: slot, Reason: why})
}
}
func hasRun(m schedule.Manifest, triggerID string) bool {
for _, r := range m.Runs {
if r.TriggerID == triggerID {
return true
}
}
return false
}
// graceFor is how late this schedule's slot may start: the default, or a quarter
// of the interval when that is shorter.
func graceFor(sc schedule.Schedule) time.Duration {
if sc.Trigger.Kind == schedule.TriggerEvery {
if q := time.Duration(sc.Trigger.EverySec) * time.Second / 4; q < DefaultLateGrace {
return q
}
}
return DefaultLateGrace
}
@@ -0,0 +1,275 @@
package scheduler
import (
"context"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/state/schedule"
)
var epoch = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
type clock struct {
mu sync.Mutex
t time.Time
}
func (c *clock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
func (c *clock) Advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
// settler is a Runner that settles what it is given, as a supervisor would.
type settler struct {
st *schedule.Store
p schedule.Policy
mu sync.Mutex
ran []string
state schedule.RunState
}
func (s *settler) Run(ctx context.Context, id string) (schedrun.Report, error) {
s.mu.Lock()
s.ran = append(s.ran, id)
state := s.state
s.mu.Unlock()
if state == "" {
state = schedule.RunSucceeded
}
return schedrun.Report{TriggerID: id, State: state}, s.st.Finish(ctx, s.p, id, schedule.Outcome{State: state, Observed: 1000, Clean: true})
}
func (s *settler) runs() []string { s.mu.Lock(); defer s.mu.Unlock(); return slices.Clone(s.ran) }
type rig struct {
t *testing.T
st *schedule.Store
clk *clock
dir string
p schedule.Policy
run *settler
}
func newRig(t *testing.T) *rig {
t.Helper()
dir := filepath.Join(t.TempDir(), "schedules")
clk := &clock{t: epoch}
st, err := schedule.Open(dir)
if err != nil {
t.Fatal(err)
}
st = st.WithClock(clk.Now)
p := schedule.DefaultPolicy()
return &rig{t: t, st: st, clk: clk, dir: dir, p: p, run: &settler{st: st, p: p}}
}
func (r *rig) add(every int64) schedule.Schedule {
r.t.Helper()
sc, err := r.st.Create(r.t.Context(), r.p, schedule.CreateRequest{
Trigger: schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: every},
Target: schedule.Target{Workspace: filepath.Join(os.TempDir(), "w")}, Prompt: "p",
Model: schedule.Model{Provider: "deepseek", Model: "m"}, Grant: schedule.Grant{Tools: []string{"read_file"}},
ConfirmedBy: schedule.ConfirmedByHuman, ToolCeiling: []string{"read_file"},
})
if err != nil {
r.t.Fatal(err)
}
return sc
}
func (r *rig) tick() Result {
r.t.Helper()
res, err := (&Scheduler{Store: r.st, Policy: r.p, Runner: r.run, Now: r.clk.Now}).Tick(r.t.Context())
if err != nil {
r.t.Fatal(err)
}
return res
}
func TestNothingDueStartsNothing(t *testing.T) {
r := newRig(t)
r.add(3600)
r.clk.Advance(30 * time.Minute)
if res := r.tick(); len(res.Reports) != 0 || len(r.run.runs()) != 0 {
t.Fatalf("started early: %+v", res)
}
}
func TestADueSlotWithinGraceRunsOnceAndNeverAgain(t *testing.T) {
r := newRig(t)
sc := r.add(3600)
r.clk.Advance(time.Hour + time.Minute)
if res := r.tick(); len(res.Reports) != 1 {
t.Fatalf("result = %+v", res)
}
r.clk.Advance(2 * time.Minute)
r.tick()
want := schedule.TriggerID(sc.ID, time.Unix(sc.Trigger.Anchor.Unix()+3600, 0).UTC())
if got := r.run.runs(); len(got) != 1 || got[0] != want {
t.Fatalf("ran %v, want only %s", got, want)
}
}
func TestASlotPastItsGraceIsSkippedNotMadeUp(t *testing.T) {
r := newRig(t)
sc := r.add(3600)
r.clk.Advance(7*24*time.Hour + 40*time.Minute)
res := r.tick()
if len(r.run.runs()) != 0 || len(res.Skipped) != 1 || res.Skipped[0].Reason != schedule.SkipMissed || res.Skipped[0].ScheduleID != sc.ID {
t.Fatalf("result = %+v ran = %v", res, r.run.runs())
}
if again := r.tick(); len(again.Skipped) != 0 || len(again.Reports) != 0 {
t.Fatalf("the same slot was judged twice: %+v", again)
}
}
func TestTheGraceIsAtMostAQuarterOfTheInterval(t *testing.T) {
r := newRig(t)
r.add(3600)
r.clk.Advance(time.Hour + 16*time.Minute)
if res := r.tick(); len(res.Reports) != 0 || len(res.Skipped) != 1 {
t.Fatalf("a slot 16 minutes late under a 15 minute grace ran: %+v", res)
}
if g := graceFor(schedule.Schedule{Trigger: schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: 600}}); g != 150*time.Second {
t.Fatalf("grace for a 10 minute interval = %s", g)
}
}
func TestTheGlobalPauseClaimsNothing(t *testing.T) {
r := newRig(t)
r.add(3600)
if err := r.st.SetPausedAll(t.Context(), true); err != nil {
t.Fatal(err)
}
r.clk.Advance(time.Hour + time.Minute)
if res := r.tick(); !res.Paused || len(r.run.runs()) != 0 {
t.Fatalf("result = %+v", res)
}
m, _, _ := r.st.Snapshot(t.Context())
if len(m.Runs) != 0 {
t.Fatalf("a paused tick left %d run record(s)", len(m.Runs))
}
}
func TestOneRunAtATimeAndTheOtherWaitsForALaterTick(t *testing.T) {
r := newRig(t)
a, b := r.add(3600), r.add(3600)
r.clk.Advance(time.Hour + time.Minute)
r.tick()
if got := r.run.runs(); len(got) != 1 {
t.Fatalf("two schedules due, ran %v", got)
}
r.clk.Advance(time.Minute)
r.tick()
got := r.run.runs()
if len(got) != 2 || got[0] == got[1] {
t.Fatalf("the second schedule did not run on the next tick: %v (a=%s b=%s)", got, a.ID, b.ID)
}
}
func TestAnInFlightRunBlocksNewClaimsWithoutSkippingTheWaitingSlot(t *testing.T) {
r := newRig(t)
a := r.add(3600)
b := r.add(3600)
r.clk.Advance(time.Hour + time.Minute)
if _, err := r.st.Claim(t.Context(), r.p, a.ID, time.Unix(a.Trigger.Anchor.Unix()+3600, 0).UTC()); err != nil {
t.Fatal(err)
}
res := r.tick()
if len(r.run.runs()) != 0 || len(res.Skipped) != 0 {
t.Fatalf("result = %+v ran = %v", res, r.run.runs())
}
m, _, _ := r.st.Snapshot(t.Context())
for _, run := range m.Runs {
if run.ScheduleID == b.ID {
t.Fatalf("the waiting schedule was recorded early: %+v", run)
}
}
}
func TestABudgetThatDoesNotFitSkipsTheSlotWithItsReason(t *testing.T) {
r := newRig(t)
sc, err := r.st.Create(t.Context(), r.p, schedule.CreateRequest{
Trigger: schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: 3600},
Target: schedule.Target{Workspace: filepath.Join(os.TempDir(), "w")}, Prompt: "p",
Model: schedule.Model{Provider: "deepseek", Model: "m"}, Grant: schedule.Grant{Tools: []string{"read_file"}},
Budget: &schedule.Budget{PerRunTokens: 100_000, PerRunWallSec: 600, PerRunSteps: 10, MaxRunsPerDay: 1, LifetimeTokens: 1_000_000},
ConfirmedBy: schedule.ConfirmedByHuman, ToolCeiling: []string{"read_file"},
})
if err != nil {
t.Fatal(err)
}
r.clk.Advance(time.Hour + time.Minute)
r.tick()
r.clk.Advance(time.Hour)
res := r.tick()
if len(r.run.runs()) != 1 || len(res.Skipped) != 1 || res.Skipped[0].Reason != schedule.SkipBudgetWindow || res.Skipped[0].ScheduleID != sc.ID {
t.Fatalf("result = %+v ran = %v", res, r.run.runs())
}
}
func TestPausedAndExpiredSchedulesAreLeftAlone(t *testing.T) {
r := newRig(t)
paused := r.add(3600)
if err := r.st.Pause(t.Context(), paused.ID); err != nil {
t.Fatal(err)
}
r.clk.Advance(time.Hour + time.Minute)
if r.tick(); len(r.run.runs()) != 0 {
t.Fatal("a paused schedule ran")
}
r.clk.Advance(31 * 24 * time.Hour)
r.tick()
if len(r.run.runs()) != 0 {
t.Fatal("an expired schedule ran")
}
}
func TestADeadRunIsReapedBeforeAnythingElse(t *testing.T) {
r := newRig(t)
sc := r.add(3600)
r.clk.Advance(time.Hour + time.Minute)
slot := time.Unix(sc.Trigger.Anchor.Unix()+3600, 0).UTC()
if _, err := r.st.Claim(t.Context(), r.p, sc.ID, slot); err != nil {
t.Fatal(err)
}
r.clk.Advance(schedule.ReapGrace + time.Minute)
res := r.tick()
if len(res.Reaped) != 1 || res.Reaped[0] != schedule.TriggerID(sc.ID, slot) {
t.Fatalf("result = %+v", res)
}
m, _, _ := r.st.Snapshot(t.Context())
if m.Runs[0].State != schedule.RunInterrupted {
t.Fatalf("state = %s", m.Runs[0].State)
}
}
func TestTwoTickersOnOneStoreStartOneRun(t *testing.T) {
r := newRig(t)
r.add(3600)
r.clk.Advance(time.Hour + time.Minute)
var wg sync.WaitGroup
for range 6 {
wg.Go(func() {
st, _ := schedule.Open(r.dir)
st = st.WithClock(r.clk.Now)
run := &settler{st: st, p: r.p}
_, _ = (&Scheduler{Store: st, Policy: r.p, Runner: run, Now: r.clk.Now}).Tick(t.Context())
r.run.mu.Lock()
r.run.ran = append(r.run.ran, run.runs()...)
r.run.mu.Unlock()
})
}
wg.Wait()
if got := r.run.runs(); len(got) != 1 {
t.Fatalf("six tickers started %d runs: %v", len(got), got)
}
}
+2 -1
View File
@@ -24,6 +24,7 @@ type CreateRequest struct {
Grant Grant
Budget *Budget
ConfirmedBy string
ConfirmedVia string
ToolCeiling []string
AllowReadOnlyBash bool
}
@@ -82,7 +83,7 @@ func (s *Store) build(p Policy, req CreateRequest, now time.Time) (Schedule, err
Model: req.Model, Grant: grant, Budget: budget, Status: StatusActive,
CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(p.Expiry()),
}
sc.Confirmed = Confirmation{At: now, By: ConfirmedByHuman, Digest: Digest(sc)}
sc.Confirmed = Confirmation{At: now, By: ConfirmedByHuman, Via: req.ConfirmedVia, Digest: Digest(sc)}
return sc, nil
}
+4
View File
@@ -56,6 +56,9 @@ const (
const ConfirmedByHuman = "human-frontend"
// ConfirmedViaTerminal records that the confirmation was typed at a terminal.
const ConfirmedViaTerminal = "terminal"
type Trigger struct {
Kind TriggerKind `json:"kind"`
At time.Time `json:"at,omitzero"`
@@ -91,6 +94,7 @@ type Budget struct {
type Confirmation struct {
At time.Time `json:"at"`
By string `json:"by"`
Via string `json:"via,omitempty"`
Digest string `json:"digest"`
}
@@ -0,0 +1,2 @@
- 新增:`reasonix schedule add|list|tick` 等命令,在终端确认后按时间只读地运行任务;需用系统计划任务调用 `schedule tick`。关联 #11406。
- Added: `reasonix schedule add|list|tick` and friends: a read-only task runs when you confirm it in a terminal; call `tick` from your OS scheduler. Refs #11406.