Files
zero/internal/cron/loopprompt.go
KRATOS 0f8ea50101 zero cron: dep-free file-backed scheduled agents (foreground runner) (#155)
* cron: dep-free 5-field cron expression parser

* cron: next-run evaluator with Vixie DOM/DOW semantics

* cron: preset recipes + loop.md prompt resolution (cap + symlink reject)

* cron: file-backed job store (metadata.json + runs.jsonl)

* cli: zero cron add/list/rm/pause/resume over the cron store

* cli: zero cron run foreground scheduler (--once/--catch-up, exec per fire)

* cron: fix adversarial-review findings (DST loop, traversal, runaway re-fire, +)

- Next: fix infinite loop on DST spring-forward gaps (advance hours by absolute
  addition + a forward-progress guard); widen the search window to 9 years so a
  Feb-29 schedule across a century non-leap year (2096->2104) isn't reported as
  impossible.
- store: reject path-traversal job ids (Get/Update/Remove/AppendRun) so
  'cron rm ../..' can't delete outside the store; List now surfaces corrupt jobs
  as a warning instead of silently dropping them.
- runner: reject impossible schedules at add even with --run-now; auto-pause a
  job whose schedule can no longer advance (was re-firing every tick); only
  skip-reschedule STRICTLY-overdue jobs on startup (keep exactly-due ones);
  capture stderr into RunRecord.Error on non-zero exit; pass the prompt via the
  inline --prompt= form so dash-leading prompts aren't misparsed.
- Regression tests for each (DST, leap gap, traversal, corrupt-list, pause,
  reconcile, dash-prompt).

* cron: address #155 review (store-error surfacing, resume guard, Runs traversal, +)

- fireJob/reconcile/cronRun now surface AppendRun/Update failures to stderr
  instead of discarding them (a failed write no longer silently re-fires/loses
  history); fireJob takes stderr.
- cron add rejects extra positional arguments instead of silently ignoring them.
- cronResume rejects an unparseable/impossible schedule instead of reactivating a
  job with a stale/zero NextRunAt.
- store.AppendRun returns the Close() error (buffered-write failures surface on
  close); store.Runs now validates the id (path-traversal guard, matching the
  other store methods).
- tests: Runs traversal rejection, cron add extra-args, resume-impossible.

* cron: check store errors in cron CLI tests

CodeRabbit (critical): the cron CLI tests ignored errors from store.List/Add/Get, so a store failure would surface as a misleading assertion on a zero-value job/slice instead of a clear failure. Check every store op's error across the test file (not only the two flagged sites).

* cron: DefaultRoot falls back to os.UserHomeDir when HOME is unset

Vasanth (P1): cron's DefaultRoot mirrored neither sessions.DefaultRoot nor reality — with no XDG_DATA_HOME and no HOME it produced a RELATIVE ".local/share/zero/cron" under the caller's cwd, so on Windows/restricted shells cron jobs scattered per working directory and could write repo-local data. Now falls back to os.UserHomeDir() like sessions.DefaultRoot. Adds TestDefaultRootEmptyHomeFallsBackToUserHome (asserts the path stays absolute).
2026-06-10 13:21:33 +05:30

50 lines
1.4 KiB
Go

package cron
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const maxLoopPromptBytes = 25000
const builtinLoopPrompt = "Continue the ongoing task in this repository.\n" +
"Inspect recent changes, make incremental progress, and stop when you reach a natural checkpoint.\n" +
"Do not take destructive actions without strong justification.\n" +
"Report what you did succinctly."
// ResolveLoopPrompt returns the loop prompt for a scheduled job: the first
// readable loop.md found in <cwd>/.zero, <cwd>/.agents, <home>/.zero,
// <home>/.agents, else the built-in fallback. Files over maxLoopPromptBytes
// return an error; symlinks are skipped (never read) to prevent exfiltration.
func ResolveLoopPrompt(cwd, home string) (string, error) {
dirs := []string{
filepath.Join(cwd, ".zero"),
filepath.Join(cwd, ".agents"),
filepath.Join(home, ".zero"),
filepath.Join(home, ".agents"),
}
for _, dir := range dirs {
path := filepath.Join(dir, "loop.md")
info, err := os.Lstat(path)
if err != nil {
continue // not present
}
if info.Mode()&os.ModeSymlink != 0 {
continue // never follow a symlinked loop.md
}
if info.Size() > maxLoopPromptBytes {
return "", fmt.Errorf("loop prompt %s exceeds %d bytes", path, maxLoopPromptBytes)
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
if text := strings.TrimSpace(string(data)); text != "" {
return text, nil
}
}
return builtinLoopPrompt, nil
}