Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions cmd/claw/schedule_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
Expand Down Expand Up @@ -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)
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion cmd/claw/spike_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/006-invoke-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 21 additions & 10 deletions internal/driver/openclaw/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down
41 changes: 22 additions & 19 deletions internal/driver/openclaw/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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")
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions internal/driver/shared/clawdapus_md.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion site/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ outline: deep

## Unreleased

<!-- Nothing yet -->
- **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 <id>` 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 <id>` contract.

## v0.8.11 <Badge type="tip" text="Latest" /> {#v0-8-11}

Expand Down