Skip to content

feat: optional start anchor for recurring cron jobs - #180

Open
joaosouz4dev wants to merge 1 commit into
memovai:mainfrom
joaosouz4dev:feat/cron-every-start-at
Open

feat: optional start anchor for recurring cron jobs#180
joaosouz4dev wants to merge 1 commit into
memovai:mainfrom
joaosouz4dev:feat/cron-every-start-at

Conversation

@joaosouz4dev

@joaosouz4dev joaosouz4dev commented May 21, 2026

Copy link
Copy Markdown

Summary

Today schedule_type: "every" always fires for the first time at (now + interval_s). Practically this makes it impossible to schedule a recurring job aligned to a wall-clock time without writing a one-shot "at" job whose message re-creates itself on every fire — a fragile pattern that depends on the LLM remembering to chain cron_add again.

Concrete pain point I hit while configuring a daily routine on a real device: the user asked for "every weekday at 10:00 local time". The LLM correctly computed the epoch for the next 10:00, called cron_add with both schedule_type: "every" and at_epoch: <future-ts>, and the API silently ignored at_epoch — the job was scheduled for now + 86400 (~3:26 AM local) instead. The first failure mode is silent, the second requires teaching the LLM a multi-step workaround, both are avoidable.

This PR lets callers pass at_epoch alongside interval_s for an "every" job. When set and in the future, the first fire happens at at_epoch and every subsequent fire follows interval_s. No behavior change for callers that only pass interval_s.

 if (job->kind == CRON_KIND_EVERY) {
-    job->next_run = now + job->interval_s;
+    if (job->at_epoch > now) {
+        job->next_run = job->at_epoch;   // honor caller-supplied anchor
+    } else {
+        job->next_run = now + job->interval_s;
+    }
 }

What's in the diff

  • main/cron/cron_service.ccompute_initial_next_run() honors at_epoch for "every" jobs when in the future.
  • main/tools/tool_cron.ccron_add reads optional at_epoch for "every", validates it's in the future, stores it on the job.
  • main/tools/tool_registry.c — the tool schema description for at_epoch now documents the new optional usage so the LLM discovers it through the tool catalogue rather than the user having to spell it out every time.

No new field on cron_job_t, no migration of cron.json on disk — at_epoch already existed for "at" jobs and was simply unused by "every" ones. Persisted jobs from before this change keep working unchanged.

Test plan

  • Existing callers: cron_add({schedule_type:"every", interval_s:60, message:"x"}) still fires now + 60s, then every 60s after.
  • New usage: cron_add({schedule_type:"every", interval_s:86400, at_epoch:<tomorrow 10:00 UTC>, message:"x"}) first fires at the supplied timestamp, then every 24h after.
  • Validation: at_epoch in the past for an "every" job returns the same "is in the past" error already used for "at" jobs.
  • Persistence: an "every" job created with at_epoch survives a reboot with the right next_run.

Summary by CodeRabbit

  • New Features

    • Recurring jobs can now be scheduled with an optional start time anchor, allowing specification of when the first execution occurs while subsequent runs follow the regular interval.
  • Documentation

    • Updated scheduling documentation to explain the new start time anchoring feature, clarifying requirements for one-time jobs and optional usage for recurring jobs.

Review Change Stack

Today schedule_type "every" always fires for the first time at
(now + interval_s), which makes it impossible to schedule a recurring
job aligned to wall-clock time without first writing a one-shot
"at" job that re-arms itself on every fire. Concrete pain point: a
user wants "every day at 10:00 local time", chains 4 tools to compute
that offset, and the LLM still tends to get it wrong.

This patch lets callers pass at_epoch alongside interval_s for an
"every" job. When set and in the future, the first fire happens at
at_epoch and subsequent fires follow interval_s as usual. Existing
callers that pass only interval_s see no change.

  main/cron/cron_service.c    use at_epoch as initial next_run when set
  main/tools/tool_cron.c      accept and validate at_epoch for "every"
  main/tools/tool_registry.c  document the new optional usage so the LLM
                              picks it up via the tool schema
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR adds optional start-anchor support for recurring cron jobs. The tool_cron_add_execute function now accepts an at_epoch field for schedule_type="every" jobs, validates it to be in the future, and passes it to the scheduler. The compute_initial_next_run() function uses the anchor as the initial execution time when present, otherwise defaults to now + interval_s.

Changes

Optional start anchor for recurring cron jobs

Layer / File(s) Summary
Input validation and tool response
main/tools/tool_registry.c, main/tools/tool_cron.c
Schema documentation clarifies at_epoch is required for 'at' jobs and optional for 'every' jobs as a future anchor. Tool parsing validates numeric at_epoch is in the future for recurring jobs and conditionally appends start-time messaging to the success response.
Recurring job scheduling with anchor support
main/cron/cron_service.c
compute_initial_next_run() now conditionally uses at_epoch as the initial next_run for CRON_KIND_EVERY jobs when the anchor is greater than the current time; otherwise computes next_run from now + interval_s.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A cron job now hops with purpose defined,
Its first fire anchored to a future time signed—
No more racing the clock from the present so fast,
The scheduler remembers when moments were cast!
✨⏰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding optional start anchor functionality (at_epoch) for recurring cron jobs, which is the core feature introduced across the modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@main/cron/cron_service.c`:
- Around line 318-325: Startup sets job->next_run using naive now +
job->interval_s which ignores anchor-aware logic in compute_initial_next_run(),
causing anchor drift when job->next_run <= 0; modify the startup path to call
compute_initial_next_run(job, now) (or the equivalent anchor-aware helper)
whenever a recurring enabled job has next_run <= 0 or job->at_epoch is set, and
assign its result to job->next_run instead of using now + job->interval_s so the
anchor semantics are preserved.

In `@main/tools/tool_cron.c`:
- Around line 71-83: The code currently ignores a present but non-numeric
"at_epoch" (found via cJSON_GetObjectItem -> start_at) and proceeds as if
absent; change the logic so that when start_at is non-NULL and
!cJSON_IsNumber(start_at) you treat this as an input error for recurring jobs:
construct an explanatory error message into output (similar style to the
existing snprintf), call cJSON_Delete(root), and return ESP_ERR_INVALID_ARG
instead of falling through to schedule now+interval_s; keep the existing numeric
branch that converts start_at->valuedouble to anchor and assigns job.at_epoch
when cJSON_IsNumber succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7521f530-0d15-4d80-8a46-280b1f8b4e69

📥 Commits

Reviewing files that changed from the base of the PR and between bb10ea0 and c4e3957.

📒 Files selected for processing (3)
  • main/cron/cron_service.c
  • main/tools/tool_cron.c
  • main/tools/tool_registry.c

Comment thread main/cron/cron_service.c
Comment on lines +318 to +325
/* Optional start anchor: if at_epoch is set and in the future, use it
* as the first fire time. Lets callers say "every 86400s starting at
* tomorrow 10:00 local time" instead of "now + 24h". */
if (job->at_epoch > now) {
job->next_run = job->at_epoch;
} else {
job->next_run = now + job->interval_s;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid anchor logic drift by reusing compute_initial_next_run() at startup.

compute_initial_next_run() is now anchor-aware, but startup recompute still uses now + interval_s for recurring jobs. If an enabled job has next_run <= 0, this can bypass anchor semantics.

Proposed fix
-    /* Recompute next_run for all enabled jobs that don't have one */
+    /* Recompute next_run for all enabled jobs that don't have one */
     time_t now = time(NULL);
     for (int i = 0; i < s_job_count; i++) {
         cron_job_t *job = &s_jobs[i];
         if (job->enabled && job->next_run <= 0) {
-            if (job->kind == CRON_KIND_EVERY) {
-                job->next_run = now + job->interval_s;
-            } else if (job->kind == CRON_KIND_AT && job->at_epoch > now) {
-                job->next_run = job->at_epoch;
-            }
+            compute_initial_next_run(job);
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main/cron/cron_service.c` around lines 318 - 325, Startup sets job->next_run
using naive now + job->interval_s which ignores anchor-aware logic in
compute_initial_next_run(), causing anchor drift when job->next_run <= 0; modify
the startup path to call compute_initial_next_run(job, now) (or the equivalent
anchor-aware helper) whenever a recurring enabled job has next_run <= 0 or
job->at_epoch is set, and assign its result to job->next_run instead of using
now + job->interval_s so the anchor semantics are preserved.

Comment thread main/tools/tool_cron.c
Comment on lines +71 to +83
cJSON *start_at = cJSON_GetObjectItem(root, "at_epoch");
if (start_at && cJSON_IsNumber(start_at)) {
int64_t anchor = (int64_t)start_at->valuedouble;
time_t now = time(NULL);
if (anchor <= now) {
snprintf(output, output_size,
"Error: at_epoch %lld is not in the future (now=%lld)",
(long long)anchor, (long long)now);
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
job.at_epoch = anchor;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject invalid at_epoch types for recurring jobs.

If at_epoch is present but not numeric, this path silently ignores it and schedules now + interval_s, which masks input errors.

Proposed fix
         cJSON *start_at = cJSON_GetObjectItem(root, "at_epoch");
-        if (start_at && cJSON_IsNumber(start_at)) {
+        if (start_at) {
+            if (!cJSON_IsNumber(start_at)) {
+                snprintf(output, output_size,
+                         "Error: 'at_epoch' must be a unix timestamp number");
+                cJSON_Delete(root);
+                return ESP_ERR_INVALID_ARG;
+            }
             int64_t anchor = (int64_t)start_at->valuedouble;
             time_t now = time(NULL);
             if (anchor <= now) {
                 snprintf(output, output_size,
                          "Error: at_epoch %lld is not in the future (now=%lld)",
                          (long long)anchor, (long long)now);
                 cJSON_Delete(root);
                 return ESP_ERR_INVALID_ARG;
             }
             job.at_epoch = anchor;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main/tools/tool_cron.c` around lines 71 - 83, The code currently ignores a
present but non-numeric "at_epoch" (found via cJSON_GetObjectItem -> start_at)
and proceeds as if absent; change the logic so that when start_at is non-NULL
and !cJSON_IsNumber(start_at) you treat this as an input error for recurring
jobs: construct an explanatory error message into output (similar style to the
existing snprintf), call cJSON_Delete(root), and return ESP_ERR_INVALID_ARG
instead of falling through to schedule now+interval_s; keep the existing numeric
branch that converts start_at->valuedouble to anchor and assigns job.at_epoch
when cJSON_IsNumber succeeds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant