diff --git a/cmd/claw/schedule_manifest_test.go b/cmd/claw/schedule_manifest_test.go index 4c579c6..e1f8330 100644 --- a/cmd/claw/schedule_manifest_test.go +++ b/cmd/claw/schedule_manifest_test.go @@ -28,6 +28,7 @@ func TestBuildScheduleManifestIncludesOnlyPodOriginInvocations(t *testing.T) { Schedule: "15 8 * * 1-5", Message: "Pre-market synthesis", Name: "Pre-market synthesis", + To: "alerts-chan", Origin: driver.OriginPod, When: &schedule.When{Calendar: "us-equities", Session: schedule.SessionRegular}, }, @@ -56,8 +57,9 @@ func TestBuildScheduleManifestIncludesOnlyPodOriginInvocations(t *testing.T) { if first.Wake.Adapter != "openclaw-exec" { t.Fatalf("expected openclaw adapter, got %q", first.Wake.Adapter) } - if len(first.Wake.Command) != 4 || first.Wake.Command[3] != "podjob01" { - t.Fatalf("expected native cron run command, got %v", first.Wake.Command) + want := []string{"openclaw", "cron", "run", "podjob01"} + if strings.Join(first.Wake.Command, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("expected openclaw cron wake command, got %v", first.Wake.Command) } if first.AgentID != "tiverton-0" { t.Fatalf("expected first ordinal agent id tiverton-0, got %q", first.AgentID) @@ -72,6 +74,35 @@ func TestBuildScheduleManifestIncludesOnlyPodOriginInvocations(t *testing.T) { } } +func TestBuildScheduleManifestOpenClawWakeUsesNativeJobID(t *testing.T) { + manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ + "bot": { + ServiceName: "bot", + ClawType: "openclaw", + Invocations: []driver.Invocation{ + { + ID: "podjob01", + Schedule: "*/10 * * * *", + Message: "Heartbeat", + Origin: driver.OriginPod, + }, + }, + }, + }) + if err != nil { + t.Fatalf("buildScheduleManifest returned error: %v", err) + } + if len(manifest.Invocations) != 1 { + t.Fatalf("expected one invocation, got %d", len(manifest.Invocations)) + } + want := []string{ + "openclaw", "cron", "run", "podjob01", + } + if strings.Join(manifest.Invocations[0].Wake.Command, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("expected openclaw wake command to target native cron id, got %v", manifest.Invocations[0].Wake.Command) + } +} + func TestBuildScheduleManifestUsesServiceTimezoneWithoutCalendar(t *testing.T) { manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ "bot": { diff --git a/cmd/claw/spike_test.go b/cmd/claw/spike_test.go index c423b77..63c52cd 100644 --- a/cmd/claw/spike_test.go +++ b/cmd/claw/spike_test.go @@ -417,7 +417,7 @@ func TestSpikeComposeUp(t *testing.T) { } // jobs.json must be readable and contain the real channel ID - out2, err2 := exec.Command("docker", "exec", containerName, "cat", "/root/.openclaw/config/cron/jobs.json").Output() + out2, err2 := exec.Command("docker", "exec", containerName, "cat", "/root/.openclaw/cron/jobs.json").Output() if err2 != nil { t.Errorf("docker exec cat jobs.json: %v", err2) } else if !strings.Contains(string(out2), channelID) { diff --git a/docs/decisions/006-invoke-scheduling.md b/docs/decisions/006-invoke-scheduling.md index 5520308..04888a8 100644 --- a/docs/decisions/006-invoke-scheduling.md +++ b/docs/decisions/006-invoke-scheduling.md @@ -9,7 +9,7 @@ The initial architecture plan stated that the `INVOKE` directive would be implem ## Decision -We have updated the enforcement mechanism for the `INVOKE` directive. Instead of relying on system cron, the `INVOKE` directive bakes scheduled tasks into the OCI image as labels (`claw.invoke.N`). At runtime, the driver extracts these labels and translates them into a runner-native scheduling format. For example, the OpenClaw driver generates a versioned `jobs.json` store under its writable config directory (`CONFIG_DIR/cron/jobs.json`; `/app/config/cron/jobs.json` in the current Clawdapus layout), allowing OpenClaw's internal scheduler to pick it up automatically. +We have updated the enforcement mechanism for the `INVOKE` directive. Instead of relying on system cron, the `INVOKE` directive bakes scheduled tasks into the OCI image as labels (`claw.invoke.N`). At runtime, the driver extracts these labels and translates them into a runner-native scheduling format. For example, the OpenClaw driver generates a versioned `jobs.json` store under the runner's writable cron directory (`~/.openclaw/cron/jobs.json` in the current OpenClaw layout), allowing OpenClaw's internal scheduler to pick it up automatically. ## Rationale diff --git a/internal/driver/openclaw/driver.go b/internal/driver/openclaw/driver.go index a320163..764985c 100644 --- a/internal/driver/openclaw/driver.go +++ b/internal/driver/openclaw/driver.go @@ -22,6 +22,7 @@ type Driver struct{} const openclawHomeDir = "/root/.openclaw" const openclawConfigDir = openclawHomeDir + "/config" const openclawConfigPath = openclawConfigDir + "/openclaw.json" +const openclawCronDir = openclawHomeDir + "/cron" const openclawWorkspaceTmpfs = "/claw:mode=1777,uid=0,gid=0" // openclawStateTmpfs mounts the writable tmpfs at /root, NOT at /root/.openclaw. @@ -86,6 +87,14 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt return nil, fmt.Errorf("openclaw driver: chmod config file: %w", err) } + cronDir := filepath.Join(opts.RuntimeDir, "cron") + if err := os.MkdirAll(cronDir, 0777); err != nil { + return nil, fmt.Errorf("openclaw driver: create cron dir: %w", err) + } + if err := os.Chmod(cronDir, 0o777); err != nil { + return nil, fmt.Errorf("openclaw driver: chmod cron dir: %w", err) + } + // Generate CLAWDAPUS.md — infrastructure context for the agent podName := opts.PodName if podName == "" { @@ -113,6 +122,14 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt ContainerPath: openclawConfigDir, ReadOnly: false, }, + { + // Cron definitions and run logs live under ~/.openclaw/cron in current + // OpenClaw builds. Mount the whole directory so the runner can atomically + // rewrite jobs.json and append run history under cron/runs/. + HostPath: cronDir, + ContainerPath: openclawCronDir, + ReadOnly: false, + }, { // Always mount as AGENTS.md so openclaw finds it at workspace root (/claw/AGENTS.md). HostPath: agentMountPath, @@ -134,21 +151,15 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt } // Generate jobs.json if there are scheduled invocations. - // OpenClaw 2026.3.24 resolves its cron store under CONFIG_DIR/cron/jobs.json, - // so keep it under the writable config directory inside the canonical home. + // OpenClaw resolves its cron store under ~/.openclaw/cron/jobs.json, separate + // from the config directory. Keep the cron dir writable because the runtime + // rewrites jobs atomically and appends run history below cron/runs/. if len(rc.Invocations) > 0 { jobsData, err := GenerateJobsJSON(rc) if err != nil { return nil, fmt.Errorf("openclaw driver: generate jobs.json: %w", err) } - jobsDir := filepath.Join(configDir, "cron") - if err := os.MkdirAll(jobsDir, 0777); err != nil { - return nil, fmt.Errorf("openclaw driver: create jobs dir: %w", err) - } - if err := os.Chmod(jobsDir, 0o777); err != nil { - return nil, fmt.Errorf("openclaw driver: chmod jobs dir: %w", err) - } - jobsPath := filepath.Join(jobsDir, "jobs.json") + jobsPath := filepath.Join(cronDir, "jobs.json") if err := os.WriteFile(jobsPath, jobsData, 0o666); err != nil { return nil, fmt.Errorf("openclaw driver: write jobs.json: %w", err) } diff --git a/internal/driver/openclaw/driver_test.go b/internal/driver/openclaw/driver_test.go index d059fcd..46e996b 100644 --- a/internal/driver/openclaw/driver_test.go +++ b/internal/driver/openclaw/driver_test.go @@ -81,9 +81,17 @@ func TestMaterializeWritesConfigAndReturnsResult(t *testing.T) { t.Fatalf("config file mode = %o, want 666", got) } - // Result should include config mount + agent mount - if len(result.Mounts) < 2 { - t.Fatalf("expected at least 2 mounts, got %d", len(result.Mounts)) + cronDirInfo, err := os.Stat(filepath.Join(dir, "cron")) + if err != nil { + t.Fatalf("stat cron dir: %v", err) + } + if got := cronDirInfo.Mode().Perm(); got != 0o777 { + t.Fatalf("cron dir mode = %o, want 777", got) + } + + // Result should include config mount + cron mount + agent mount. + if len(result.Mounts) < 3 { + t.Fatalf("expected at least 3 mounts, got %d", len(result.Mounts)) } if !result.ReadOnly { @@ -309,7 +317,7 @@ func TestMaterializeInlinesClawdapusContextIntoMountedContract(t *testing.T) { } } -func TestMaterializeWritesJobsUnderConfigDir(t *testing.T) { +func TestMaterializeWritesJobsUnderCronDir(t *testing.T) { dir := t.TempDir() agentFile := filepath.Join(dir, "AGENTS.md") os.WriteFile(agentFile, []byte("# Contract"), 0644) @@ -331,13 +339,11 @@ func TestMaterializeWritesJobsUnderConfigDir(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // jobs.json must exist in the config/cron/ directory on the host. - // The parent ~/.openclaw/config bind mount covers this path inside the container. - jobsPath := filepath.Join(dir, "config", "cron", "jobs.json") + jobsPath := filepath.Join(dir, "cron", "jobs.json") if _, err := os.Stat(jobsPath); err != nil { - t.Fatalf("jobs.json not written at config/cron/jobs.json: %v", err) + t.Fatalf("jobs.json not written at cron/jobs.json: %v", err) } - jobsDirInfo, err := os.Stat(filepath.Join(dir, "config", "cron")) + jobsDirInfo, err := os.Stat(filepath.Join(dir, "cron")) if err != nil { t.Fatalf("stat jobs dir: %v", err) } @@ -352,26 +358,23 @@ func TestMaterializeWritesJobsUnderConfigDir(t *testing.T) { t.Fatalf("jobs.json mode = %o, want 666", got) } - var configMount *driver.Mount + var cronMount *driver.Mount for i := range result.Mounts { - if result.Mounts[i].ContainerPath == openclawConfigDir { - configMount = &result.Mounts[i] + if result.Mounts[i].ContainerPath == openclawCronDir { + cronMount = &result.Mounts[i] break } } - if configMount == nil { - t.Fatalf("expected %s mount to cover config/cron/jobs.json", openclawConfigDir) + if cronMount == nil { + t.Fatalf("expected %s mount to cover cron/jobs.json", openclawCronDir) } - if configMount.ReadOnly { - t.Errorf("%s must be read-write so openclaw can update cron job state", openclawConfigDir) + if cronMount.ReadOnly { + t.Errorf("%s must be read-write so openclaw can update cron job state", openclawCronDir) } for i := range result.Mounts { if result.Mounts[i].ContainerPath == "/app/state/cron" { t.Fatal("unexpected legacy /app/state/cron mount") } - if result.Mounts[i].ContainerPath == "/root/.openclaw/cron" { - t.Fatal("unexpected direct /root/.openclaw/cron mount; jobs should stay under config dir") - } } } diff --git a/internal/driver/shared/clawdapus_md.go b/internal/driver/shared/clawdapus_md.go index 400365e..69bc52d 100644 --- a/internal/driver/shared/clawdapus_md.go +++ b/internal/driver/shared/clawdapus_md.go @@ -53,6 +53,17 @@ func GenerateClawdapusMD(rc *driver.ResolvedClaw, podName string) string { } b.WriteString("\n") + if len(rc.Invocations) > 0 { + b.WriteString("## Scheduling\n\n") + b.WriteString("Some scheduled invocations are infrastructure-owned.\n\n") + b.WriteString("- **Compile-time source:** Clawdapus compiles image and pod INVOKE entries into runner/runtime artifacts during `claw up`.\n") + if rc.ClawType == "openclaw" { + b.WriteString("- **Wake authority:** pod-origin schedules are fired by `claw-api`, not by relying on OpenClaw's own cron timing.\n") + b.WriteString("- **Do not treat native cron as durable state:** runner-native cron entries you create yourself may conflict with infrastructure-owned schedules and can be overwritten by the next `claw up`.\n") + } + b.WriteString("- **Operator rule:** if a new recurring task should persist, add it to the pod or image definition instead of creating it ad hoc inside the runner.\n\n") + } + // Surfaces b.WriteString("## Surfaces\n\n") if len(rc.Surfaces) == 0 { diff --git a/site/changelog.md b/site/changelog.md index 5577474..2510643 100644 --- a/site/changelog.md +++ b/site/changelog.md @@ -29,7 +29,7 @@ outline: deep ## Unreleased - +- **Fix: OpenClaw scheduled jobs are materialized under the canonical cron store again** ([#159](https://github.com/mostlydev/clawdapus/issues/159)) — the OpenClaw driver now mounts a writable `~/.openclaw/cron/` directory and writes `jobs.json` there instead of under the config directory. Current OpenClaw builds resolve cron definitions from `~/.openclaw/cron/jobs.json`, so the previous layout left `openclaw cron list` empty and `openclaw cron run ` failed against jobs Clawdapus thought it had compiled. `claw up` now emits the native store where OpenClaw actually reads it, preserves the dedicated cron directory mount, and keeps pod-origin wakes targeting the runner-native `openclaw cron run ` contract. ## v0.8.11 {#v0-8-11}