mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
12 files changed
+1155
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user