From 82856bc57aa0af52dc8c3f9b563b90af2d030126 Mon Sep 17 00:00:00 2001 From: yinwm Date: Sun, 15 Feb 2026 18:41:39 +0800 Subject: [PATCH 1/2] feat(cron): add configurable execution timeout for cron jobs Add a new configuration option `exec_timeout_minutes` under the `tools.cron` section to control the maximum execution time for cron jobs. The default timeout is set to 5 minutes, which is appropriate for LLM operations. The configuration can be set in the config file or via the `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES` environment variable. A value of 0 disables the timeout entirely. This change improves system reliability by preventing cron jobs from running indefinitely in case of unexpected failures or hanging processes. --- README.ja.md | 6 ++++++ README.md | 3 +++ README.zh.md | 6 ++++++ cmd/picoclaw/main.go | 6 +++--- config/config.example.json | 3 +++ pkg/config/config.go | 10 +++++++++- pkg/tools/cron.go | 8 ++++++-- 7 files changed, 36 insertions(+), 6 deletions(-) diff --git a/README.ja.md b/README.ja.md index 48105ce2..5e4e4941 100644 --- a/README.ja.md +++ b/README.ja.md @@ -195,6 +195,9 @@ picoclaw onboard "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { @@ -646,6 +649,9 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る "search": { "apiKey": "BSA..." } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/README.md b/README.md index 2ba70881..1b7537fc 100644 --- a/README.md +++ b/README.md @@ -697,6 +697,9 @@ picoclaw agent -m "Hello" "search": { "api_key": "BSA..." } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/README.zh.md b/README.zh.md index f2c9bf78..877cb0f5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -217,6 +217,9 @@ picoclaw onboard "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } } } @@ -625,6 +628,9 @@ picoclaw agent -m "你好" "search": { "api_key": "BSA..." } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 21246cf4..8225931c 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -669,7 +669,7 @@ func gatewayCmd() { }) // Setup cron tool and service - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath()) + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes)*time.Minute) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -1069,14 +1069,14 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService { +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, execTimeout time.Duration) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, execTimeout) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/config/config.example.json b/config/config.example.json index c71587a0..d56596f2 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -98,6 +98,9 @@ "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/pkg/config/config.go b/pkg/config/config.go index 391120e2..9acbcce8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -173,8 +173,13 @@ type WebToolsConfig struct { Search WebSearchConfig `json:"search"` } +type CronToolsConfig struct { + ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout +} + type ToolsConfig struct { - Web WebToolsConfig `json:"web"` + Web WebToolsConfig `json:"web"` + Cron CronToolsConfig `json:"cron"` } func DefaultConfig() *Config { @@ -262,6 +267,9 @@ func DefaultConfig() *Config { MaxResults: 5, }, }, + Cron: CronToolsConfig{ + ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 0ef745e2..8632b07b 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -28,12 +28,16 @@ type CronTool struct { } // NewCronTool creates a new CronTool -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string) *CronTool { +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, execTimeout time.Duration) *CronTool { + execTool := NewExecTool(workspace, false) + if execTimeout > 0 { + execTool.SetTimeout(execTimeout) + } return &CronTool{ cronService: cronService, executor: executor, msgBus: msgBus, - execTool: NewExecTool(workspace, false), + execTool: execTool, } } From 881999aceb5a8d63742691e2e1bcc81d98ef30c7 Mon Sep 17 00:00:00 2001 From: yinwm Date: Tue, 17 Feb 2026 21:10:20 +0800 Subject: [PATCH 2/2] refactor(shell): interpret zero timeout as unlimited execution Replace unconditional WithTimeout usage with conditional context creation based on timeout configuration. Zero values now bypass timeout enforcement, using WithCancel for graceful cancellation while preserving existing timeout behavior for positive values. Simplifies CronTool initialization by removing unnecessary conditional timeout assignment. --- pkg/tools/cron.go | 5 ++--- pkg/tools/shell.go | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index af23dba0..21bee42e 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -28,11 +28,10 @@ type CronTool struct { } // NewCronTool creates a new CronTool +// execTimeout: 0 means no timeout, >0 sets the timeout duration func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool { execTool := NewExecTool(workspace, restrict) - if execTimeout > 0 { - execTool.SetTimeout(execTimeout) - } + execTool.SetTimeout(execTimeout) // 0 means no timeout return &CronTool{ cronService: cronService, executor: executor, diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 1ca3fc35..713850f9 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -89,7 +89,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To return ErrorResult(guardError) } - cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) + // timeout == 0 means no timeout + var cmdCtx context.Context + var cancel context.CancelFunc + if t.timeout > 0 { + cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) + } else { + cmdCtx, cancel = context.WithCancel(ctx) + } defer cancel() var cmd *exec.Cmd