mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
* feat(cron): deterministic command payloads (run a shell command, no LLM) Cron jobs always run an agent turn today, so deterministic work (health probes, backups, syncs) pays model tokens on every fire. This adds a "command" payload kind that runs a shell command directly in the gateway process with zero model tokens, mirroring openclaw's command cron. - store: CronPayload.Command (*CronCommandSpec — argv/cwd/env/input/ timeouts/output cap). Persists in the existing payload JSON blob, so there is NO migration and no schema version bump. - internal/cronexec: in-process runner with wall-clock + no-output timeouts, per-stream output capping, and process-group termination so a timed-out command's forked children are also killed. - gateway_cron handler: command jobs run in-process and deliver stdout on success (honoring the NO_REPLY sentinel). A non-zero exit / timeout returns an error so the run is recorded as error and retried per cron.max_retries; failures are NOT delivered, mirroring the agent path (only successful output is announced — no channel spam). - surfaces: cron.create RPC, the agent `cron` tool, and a new `goclaw cron create` CLI all accept command payloads. - security: gated by cron.command_enabled (default false). Commands run with the gateway process's privileges, so the feature is opt-in per gateway; when disabled the RPC and tool reject command payloads and the handler refuses to run them. - i18n (en/vi/zh), docs (08-scheduling-cron.md), and tests for the runner and the handler command path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cron): gate command payloads on the update surfaces too handleUpdate (RPC + agent tool) passed CronJobPatch.Command straight to UpdateJob, which switches the payload to command kind for any non-nil Command — without the command_enabled gate or ValidateCronCommandSpec that create enforces. A normal job could therefore be mutated into a command job (or persisted with an invalid spec, e.g. empty argv) on a gateway where command cron is disabled, breaking the disabled-gateway contract. Both update surfaces now require cron.command_enabled and validate the spec before UpdateJob, matching create. The agent tool parses the command via the same path as add and drops the raw keys so a shell-string command can't break the generic patch unmarshal. Regression tests added for RPC and tool update (command disabled + invalid argv), plus a positive enabled-valid case. Addresses review feedback from @mrgoonie on #1279. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
6.9 KiB
Go
271 lines
6.9 KiB
Go
// Package cronexec runs deterministic shell-command cron payloads inside the
|
|
// gateway process WITHOUT invoking an LLM. It mirrors openclaw's
|
|
// src/cron/command-runner.ts: wall-clock timeout, a no-output watchdog, output
|
|
// capping, and process-group termination so a timed-out command's forked
|
|
// children do not survive.
|
|
package cronexec
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// DefaultOutputMaxBytes caps captured stdout/stderr per stream when the spec
|
|
// does not set OutputMaxBytes. 16 KiB matches the cron run-log truncation.
|
|
DefaultOutputMaxBytes = 16 * 1024
|
|
// killGrace is how long to wait after SIGTERM before escalating to SIGKILL.
|
|
killGrace = 5 * time.Second
|
|
// watchdogInterval is how often the no-output watchdog wakes to check.
|
|
watchdogInterval = 250 * time.Millisecond
|
|
)
|
|
|
|
// Run statuses.
|
|
const (
|
|
StatusOK = "ok"
|
|
StatusError = "error"
|
|
)
|
|
|
|
// Termination reasons (empty when the process exited on its own).
|
|
const (
|
|
TermTimeout = "timeout"
|
|
TermNoOutputTimeout = "no-output-timeout"
|
|
)
|
|
|
|
// Spec is a deterministic command to run.
|
|
type Spec struct {
|
|
Argv []string
|
|
Cwd string
|
|
Env map[string]string
|
|
Input string
|
|
Timeout time.Duration // wall-clock; <=0 means no explicit limit (still bounded by ctx)
|
|
NoOutputTimeout time.Duration // kill if no stdout/stderr for this long; <=0 disables
|
|
OutputMaxBytes int // per-stream cap; <=0 uses DefaultOutputMaxBytes
|
|
}
|
|
|
|
// Result is the outcome of a command run.
|
|
type Result struct {
|
|
Status string // StatusOK or StatusError
|
|
ExitCode int // process exit code (-1 if not started or signalled)
|
|
Stdout string
|
|
Stderr string
|
|
Summary string // stdout (preferred), else stderr, else combined
|
|
Termination string // "", TermTimeout, or TermNoOutputTimeout
|
|
Err error // non-nil when Status==StatusError
|
|
}
|
|
|
|
// Run executes spec and returns its outcome. It never returns an error itself;
|
|
// failures are reported via Result.Status/Result.Err so callers can record a
|
|
// run log uniformly.
|
|
func Run(ctx context.Context, spec Spec) Result {
|
|
if len(spec.Argv) == 0 {
|
|
return Result{Status: StatusError, ExitCode: -1, Err: errors.New("command requires non-empty argv")}
|
|
}
|
|
|
|
runCtx := ctx
|
|
if spec.Timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
runCtx, cancel = context.WithTimeout(ctx, spec.Timeout)
|
|
defer cancel()
|
|
}
|
|
|
|
cmd := exec.Command(spec.Argv[0], spec.Argv[1:]...)
|
|
if spec.Cwd != "" {
|
|
cmd.Dir = spec.Cwd
|
|
}
|
|
if len(spec.Env) > 0 {
|
|
cmd.Env = mergedEnv(spec.Env)
|
|
}
|
|
if spec.Input != "" {
|
|
cmd.Stdin = strings.NewReader(spec.Input)
|
|
}
|
|
|
|
maxBytes := spec.OutputMaxBytes
|
|
if maxBytes <= 0 {
|
|
maxBytes = DefaultOutputMaxBytes
|
|
}
|
|
stdout := newCappedBuffer(maxBytes)
|
|
stderr := newCappedBuffer(maxBytes)
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
setProcessGroup(cmd) // platform-specific: own process group for tree kill
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return Result{Status: StatusError, ExitCode: -1, Err: fmt.Errorf("command failed to start: %w", err)}
|
|
}
|
|
|
|
waitErr := make(chan error, 1)
|
|
go func() { waitErr <- cmd.Wait() }()
|
|
|
|
start := time.Now()
|
|
var watchC <-chan time.Time
|
|
if spec.NoOutputTimeout > 0 {
|
|
ticker := time.NewTicker(watchdogInterval)
|
|
defer ticker.Stop()
|
|
watchC = ticker.C
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case err := <-waitErr:
|
|
return finalize(stdout, stderr, err, "")
|
|
case <-runCtx.Done():
|
|
return finalize(stdout, stderr, terminate(cmd, waitErr), TermTimeout)
|
|
case <-watchC:
|
|
last := latest(stdout.LastWrite(), stderr.LastWrite())
|
|
if last.IsZero() {
|
|
last = start
|
|
}
|
|
if time.Since(last) >= spec.NoOutputTimeout {
|
|
return finalize(stdout, stderr, terminate(cmd, waitErr), TermNoOutputTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// terminate signals the process group (SIGTERM, then SIGKILL after killGrace),
|
|
// waits for the process to be reaped, and returns cmd.Wait()'s error.
|
|
func terminate(cmd *exec.Cmd, waitErr <-chan error) error {
|
|
signalGroup(cmd, false) // SIGTERM
|
|
t := time.AfterFunc(killGrace, func() { signalGroup(cmd, true) }) // SIGKILL
|
|
err := <-waitErr
|
|
t.Stop()
|
|
return err
|
|
}
|
|
|
|
func finalize(stdout, stderr *cappedBuffer, waitErr error, termination string) Result {
|
|
outStr := stdout.String()
|
|
errStr := stderr.String()
|
|
|
|
exitCode := 0
|
|
signalled := false
|
|
if waitErr != nil {
|
|
var ee *exec.ExitError
|
|
if errors.As(waitErr, &ee) {
|
|
exitCode = ee.ExitCode() // -1 when terminated by a signal
|
|
if exitCode < 0 {
|
|
signalled = true
|
|
}
|
|
} else {
|
|
exitCode = -1
|
|
}
|
|
}
|
|
|
|
res := Result{
|
|
ExitCode: exitCode,
|
|
Stdout: outStr,
|
|
Stderr: errStr,
|
|
Summary: buildSummary(outStr, errStr),
|
|
Termination: termination,
|
|
}
|
|
|
|
if termination == "" && waitErr == nil && exitCode == 0 && !signalled {
|
|
res.Status = StatusOK
|
|
return res
|
|
}
|
|
res.Status = StatusError
|
|
res.Err = commandError(termination, exitCode, errStr)
|
|
return res
|
|
}
|
|
|
|
func commandError(termination string, exitCode int, stderr string) error {
|
|
switch termination {
|
|
case TermTimeout:
|
|
return errors.New("command timed out")
|
|
case TermNoOutputTimeout:
|
|
return errors.New("command produced no output before the no-output timeout")
|
|
}
|
|
msg := fmt.Sprintf("command exited with code %d", exitCode)
|
|
if s := strings.TrimSpace(stderr); s != "" {
|
|
msg += ": " + truncate(s, 500)
|
|
}
|
|
return errors.New(msg)
|
|
}
|
|
|
|
// buildSummary mirrors openclaw: prefer stdout, fall back to stderr, and when
|
|
// both are present, label them.
|
|
func buildSummary(stdout, stderr string) string {
|
|
so := strings.TrimSpace(stdout)
|
|
se := strings.TrimSpace(stderr)
|
|
switch {
|
|
case so != "" && se != "":
|
|
return "stdout:\n" + so + "\n\nstderr:\n" + se
|
|
case so != "":
|
|
return so
|
|
default:
|
|
return se
|
|
}
|
|
}
|
|
|
|
func mergedEnv(extra map[string]string) []string {
|
|
env := os.Environ()
|
|
for k, v := range extra {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func latest(a, b time.Time) time.Time {
|
|
if b.After(a) {
|
|
return b
|
|
}
|
|
return a
|
|
}
|
|
|
|
func truncate(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "...[truncated]"
|
|
}
|
|
|
|
// cappedBuffer is an io.Writer that retains at most max bytes and records the
|
|
// time of the last write (for the no-output watchdog). Writes never error or
|
|
// block, so the child process is never throttled by a full buffer.
|
|
type cappedBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
max int
|
|
last time.Time
|
|
}
|
|
|
|
func newCappedBuffer(max int) *cappedBuffer {
|
|
return &cappedBuffer{max: max}
|
|
}
|
|
|
|
func (c *cappedBuffer) Write(p []byte) (int, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.last = time.Now()
|
|
if c.max > 0 {
|
|
remaining := c.max - c.buf.Len()
|
|
if remaining <= 0 {
|
|
return len(p), nil // capped: discard but report full write
|
|
}
|
|
if len(p) > remaining {
|
|
c.buf.Write(p[:remaining])
|
|
return len(p), nil
|
|
}
|
|
}
|
|
return c.buf.Write(p)
|
|
}
|
|
|
|
func (c *cappedBuffer) String() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.buf.String()
|
|
}
|
|
|
|
func (c *cappedBuffer) LastWrite() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.last
|
|
}
|