From 8261379515487565afbfb68964b2de9e8a3636d5 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 10 Apr 2026 17:00:10 -0400 Subject: [PATCH] Fix OpenClaw cron timeouts and portable memory permissions --- internal/driver/openclaw/jobs.go | 49 ++++++++++++++++++++++++++- internal/driver/openclaw/jobs_test.go | 46 +++++++++++++++++++++++++ internal/driver/shared/memory.go | 29 +++++++++++++++- internal/driver/shared/memory_test.go | 41 ++++++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/internal/driver/openclaw/jobs.go b/internal/driver/openclaw/jobs.go index abcddf4..c1ed3ea 100644 --- a/internal/driver/openclaw/jobs.go +++ b/internal/driver/openclaw/jobs.go @@ -4,12 +4,15 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "strconv" "strings" "time" "github.com/mostlydev/clawdapus/internal/driver" ) +const defaultInvocationTimeoutSeconds = 300 + type job struct { ID string `json:"id"` AgentID string `json:"agentId"` @@ -61,6 +64,10 @@ type jobState struct { // so re-running claw up is idempotent. func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { now := time.Now().UnixMilli() + timeoutSeconds, err := resolvedInvocationTimeoutSeconds(rc) + if err != nil { + return nil, err + } jobs := make([]job, 0, len(rc.Invocations)) for _, inv := range rc.Invocations { name := inv.Name @@ -81,7 +88,7 @@ func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { Schedule: jobSchedule{Expr: inv.Schedule, TZ: "UTC", Kind: "cron"}, SessionTarget: "isolated", WakeMode: "now", - Payload: jobPayload{Kind: "agentTurn", Message: inv.Message, TimeoutSeconds: 300}, + Payload: jobPayload{Kind: "agentTurn", Message: inv.Message, TimeoutSeconds: timeoutSeconds}, Delivery: jobDelivery{Mode: "announce", BestEffort: true, To: inv.To}, State: jobState{}, } @@ -93,6 +100,46 @@ func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { }, "", " ") } +func resolvedInvocationTimeoutSeconds(rc *driver.ResolvedClaw) (int, error) { + timeoutSeconds := defaultInvocationTimeoutSeconds + if rc == nil { + return timeoutSeconds, nil + } + for _, cmd := range rc.Configures { + path, value, err := parseConfigSetCommand(cmd) + if err != nil { + return 0, fmt.Errorf("jobs generation: %w", err) + } + if path != "agents.defaults.timeoutSeconds" { + continue + } + timeoutSeconds, err = positiveIntConfigValue(path, value) + if err != nil { + return 0, fmt.Errorf("jobs generation: %w", err) + } + } + return timeoutSeconds, nil +} + +func positiveIntConfigValue(path string, value any) (int, error) { + switch v := value.(type) { + case float64: + parsed := int(v) + if float64(parsed) != v || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer, got %v", path, value) + } + return parsed, nil + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer, got %q", path, v) + } + return parsed, nil + default: + return 0, fmt.Errorf("%s must be a positive integer, got %T", path, value) + } +} + func deterministicJobID(parts ...string) string { h := sha256.Sum256([]byte(strings.Join(parts, "|"))) return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", diff --git a/internal/driver/openclaw/jobs_test.go b/internal/driver/openclaw/jobs_test.go index 86c5eee..7937e0a 100644 --- a/internal/driver/openclaw/jobs_test.go +++ b/internal/driver/openclaw/jobs_test.go @@ -65,6 +65,9 @@ func TestGenerateJobsJSONSingleInvocation(t *testing.T) { if payload["message"] != "Pre-market synthesis" { t.Errorf("expected payload.message=%q, got %v", "Pre-market synthesis", payload["message"]) } + if payload["timeoutSeconds"] != float64(300) { + t.Errorf("expected payload.timeoutSeconds=300, got %v", payload["timeoutSeconds"]) + } delivery := j["delivery"].(map[string]interface{}) if delivery["to"] != "111222333444" { @@ -183,3 +186,46 @@ func TestGenerateJobsJSONDisablesPodOriginJobs(t *testing.T) { t.Fatalf("expected explicit invocation id to be preserved, got %v", jobs[0]["id"]) } } + +func TestGenerateJobsJSONUsesConfiguredAgentTimeout(t *testing.T) { + rc := &driver.ResolvedClaw{ + ServiceName: "allen", + Configures: []string{ + "openclaw config set agents.defaults.timeoutSeconds 900", + }, + Invocations: []driver.Invocation{ + { + Schedule: "0 7 * * 1-5", + Message: "Morning research scan", + }, + }, + } + data, err := GenerateJobsJSON(rc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + jobs := parseJobStore(t, data) + payload := jobs[0]["payload"].(map[string]interface{}) + if payload["timeoutSeconds"] != float64(900) { + t.Fatalf("expected configured payload timeoutSeconds=900, got %v", payload["timeoutSeconds"]) + } +} + +func TestGenerateJobsJSONRejectsInvalidConfiguredAgentTimeout(t *testing.T) { + rc := &driver.ResolvedClaw{ + ServiceName: "allen", + Configures: []string{ + `openclaw config set agents.defaults.timeoutSeconds "slow"`, + }, + Invocations: []driver.Invocation{ + { + Schedule: "0 7 * * 1-5", + Message: "Morning research scan", + }, + }, + } + if _, err := GenerateJobsJSON(rc); err == nil { + t.Fatal("expected invalid timeoutSeconds configure to fail") + } +} diff --git a/internal/driver/shared/memory.go b/internal/driver/shared/memory.go index f2aa9d7..97fdf8a 100644 --- a/internal/driver/shared/memory.go +++ b/internal/driver/shared/memory.go @@ -34,14 +34,41 @@ func PreparePortableMemory(stateDir string, extraImportRoots ...string) (string, } else if !os.IsNotExist(err) { return "", fmt.Errorf("stat portable memory file %q: %w", path, err) } - if err := os.WriteFile(path, nil, 0o644); err != nil { + if err := os.WriteFile(path, nil, 0o666); err != nil { return "", fmt.Errorf("seed portable memory file %q: %w", path, err) } } + if err := normalizePortableMemoryPermissions(memoryDir); err != nil { + return "", err + } return memoryDir, nil } +func normalizePortableMemoryPermissions(memoryDir string) error { + return filepath.Walk(memoryDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("walk portable memory %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + if info.IsDir() { + if err := os.Chmod(path, 0o777); err != nil { + return fmt.Errorf("chmod portable memory dir %q: %w", path, err) + } + return nil + } + if !info.Mode().IsRegular() { + return nil + } + if err := os.Chmod(path, 0o666); err != nil { + return fmt.Errorf("chmod portable memory file %q: %w", path, err) + } + return nil + }) +} + func legacyPortableMemoryDirs(runtimeDir string) []string { return []string{ filepath.Join(runtimeDir, "hermes-home", "memories"), diff --git a/internal/driver/shared/memory_test.go b/internal/driver/shared/memory_test.go index 9e38f0a..4385982 100644 --- a/internal/driver/shared/memory_test.go +++ b/internal/driver/shared/memory_test.go @@ -114,3 +114,44 @@ func TestPreparePortableMemoryImportsFromExtraRuntimeRoot(t *testing.T) { t.Fatalf("unexpected migrated memory: %q", string(data)) } } + +func TestPreparePortableMemoryNormalizesExistingPermissions(t *testing.T) { + stateDir := t.TempDir() + memoryDir := filepath.Join(stateDir, "memory") + nestedDir := filepath.Join(memoryDir, "notes") + + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(memoryDir, "MEMORY.md"), []byte("canonical"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedDir, "2026-04-10.md"), []byte("note"), 0o640); err != nil { + t.Fatal(err) + } + + gotDir, err := PreparePortableMemory(stateDir) + if err != nil { + t.Fatalf("PreparePortableMemory returned error: %v", err) + } + if gotDir != memoryDir { + t.Fatalf("unexpected memory dir: %q", gotDir) + } + + checks := map[string]os.FileMode{ + memoryDir: 0o777, + filepath.Join(memoryDir, "MEMORY.md"): 0o666, + filepath.Join(memoryDir, "USER.md"): 0o666, + nestedDir: 0o777, + filepath.Join(nestedDir, "2026-04-10.md"): 0o666, + } + for path, want := range checks { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("%s mode=%o want %o", path, got, want) + } + } +}