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 48105ce2f..5e4e49411 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 2ba70881b..1b7537fc9 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 f2c9bf780..877cb0f5d 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 21246cf41..8225931c8 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 c71587a04..d56596f24 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 391120e2d..9acbcce8c 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 0ef745e2b..8632b07b9 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 af23dba00..21bee42ef 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 1ca3fc35a..713850f97 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