Skip to content

fix(cron): recover flat schedule params and coerce non-object job values#57640

Closed
tyxben wants to merge 2 commits into
openclaw:mainfrom
tyxben:fix/cron-tool-flat-params-56996
Closed

fix(cron): recover flat schedule params and coerce non-object job values#57640
tyxben wants to merge 2 commits into
openclaw:mainfrom
tyxben:fix/cron-tool-flat-params-56996

Conversation

@tyxben

@tyxben tyxben commented Mar 30, 2026

Copy link
Copy Markdown

Summary

Fixes #56996cron add fails with "job: must be object" when LLMs send flat parameters.

Root cause: Two issues in cron-tool.ts:

  1. The job field used Type.Object in the tool schema, which rejects null/non-object values at provider-level validation before the tool's flat-params recovery can run.
  2. The flat-params recovery only captured structured job fields (name, message, schedule, etc.) but missed schedule shorthand keys (every, everyMs, cron, at, tz), so LLM-generated flat params like { every: 300000, message: "ping" } lost the schedule.

Changes:

  • Relax job/patch schema from Type.Object to Type.Unsafe({}) so provider-level validation accepts any value
  • Coerce non-object job/patch values (null, string) to undefined before flat-params recovery
  • Add schedule shorthand keys to JOB_KEYS / PATCH_KEYS (every, everyMs, cron, at, tz, stagger, staggerMs, exact)
  • Reconstruct schedule object from shorthand keys when not already present
  • Clean up shorthand keys after folding into schedule to avoid downstream schema rejection
  • Apply the same fix to both add and update code paths

Test plan

  • 6 new test cases in cron-tool.flat-params.test.ts:
    • every shorthand → reconstructs { kind: "every", everyMs } schedule
    • cron + tz shorthand → reconstructs { kind: "cron", expr, tz } schedule
    • at shorthand → reconstructs { kind: "at", at } schedule
    • everyMs shorthand → reconstructs schedule correctly
    • job: null → coerced to undefined, triggers flat-params recovery
    • update action with every shorthand → reconstructs schedule in patch
  • Existing 33 cron-tool tests pass
  • Existing flat-params test (sessionKey preservation) passes

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M labels Mar 30, 2026
@greptile-apps

greptile-apps Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two related bugs in cron-tool.ts that caused cron add to fail with "job: must be object" when LLMs sent flat parameters: the job/patch schema is relaxed from Type.Object to Type.Unsafe to survive provider-level validation, and schedule shorthand keys (every, everyMs, cron, at, tz, stagger, staggerMs, exact) are added to the flat-params recovery sets so LLM-generated flat calls are correctly reconstructed. The same fix is applied symmetrically to both the add and update code paths, and six new test cases verify the new behaviour.

Key findings:

  • staggerMs silently dropped in cron reconstructionstaggerMs is listed in JOB_KEYS (meaning it is expected as a flat param), but the cron-shorthand reconstruction block only spreads tz into the synthetic schedule. The cleanup loop then deletes staggerMs, so { cron: "0 0 * * *", staggerMs: 5000, ... } loses the stagger value. The same gap exists in the update path. A one-line spread would fix it.

  • Misleading null-coercion comment – The comment (and the test name) claim that null is coerced to undefined, but typeof null === "object" means the guard params.job != null && typeof params.job !== "object" never fires for null. Null is handled correctly downstream by !params.job being truthy, but the comment misrepresents the code, which could confuse future maintainers.

No test covers staggerMs as a flat param; adding one would document the expected behaviour (or surface the gap).

Confidence Score: 5/5

Safe to merge; the root-cause fix is correct and all remaining findings are P2 edge-case gaps that do not affect the primary bug being addressed.

Both open findings are P2: the staggerMs data-loss only occurs on a combination (cron shorthand + staggerMs flat param) that no current LLM is known to produce, and the misleading comment does not affect runtime behaviour. The core fix — schema relaxation, null/string coercion, and every/cron/at reconstruction — is correct and well-tested.

src/agents/tools/cron-tool.ts — specifically the two duplicate schedule-reconstruction blocks (add and update paths) where staggerMs should be spread into the cron schedule object.

Important Files Changed

Filename Overview
src/agents/tools/cron-tool.ts Schema relaxation and flat-params recovery extended; staggerMs is captured in JOB_KEYS but not propagated into the reconstructed cron schedule, causing silent data loss if an LLM sends it as a flat param
src/agents/tools/cron-tool.flat-params.test.ts Six well-targeted test cases added for the new shorthand paths; no coverage for staggerMs as a flat param alongside cron
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/tools/cron-tool.ts
Line: 363-386

Comment:
**`staggerMs` silently dropped after cron-shorthand reconstruction**

`staggerMs` is now captured in `JOB_KEYS` (signalling that LLMs may send it as a flat param), but the `cron` shorthand reconstruction only spreads `tz` into the synthetic schedule — `staggerMs` is never added. The cleanup loop then deletes it, so a flat call like `{ cron: "0 0 * * *", staggerMs: 5000, message: "ping" }` produces `{ kind: "cron", expr: "0 0 * * *" }` with `staggerMs` silently discarded.

The same gap exists in the `update` code path (the duplicate reconstruction block around lines 556-600).

Consider spreading `staggerMs` into the cron schedule when present:

```typescript
} else if (typeof synthetic.cron === "string" && synthetic.cron.trim()) {
  synthetic.schedule = {
    kind: "cron",
    expr: synthetic.cron.trim(),
    ...(typeof synthetic.tz === "string" && synthetic.tz.trim()
      ? { tz: synthetic.tz.trim() }
      : {}),
    ...(typeof synthetic.staggerMs === "number" ? { staggerMs: synthetic.staggerMs } : {}),
  };
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/tools/cron-tool.ts
Line: 308-312

Comment:
**Misleading comment — `null` is not coerced here**

The comment says "Coerce non-object job values **(null**, string) to undefined", but because `typeof null === "object"` in JavaScript, the condition `params.job != null && typeof params.job !== "object"` evaluates as:

- `null`: `null != null``false` → the branch is **never entered**; `params.job` stays `null`
- `"some string"`: both checks pass → correctly coerced to `undefined`

`null` still works because the immediately-following `!params.job` check is truthy for `null`, so flat-params recovery does trigger. But the comment and the test name (`"coerces null job to undefined"`) misrepresent what the code actually does, which could mislead future readers.

```suggestion
          // Coerce non-null non-object job values (e.g. a bare string) to undefined so
          // flat-params recovery triggers instead of failing schema validation (#56996).
          // Note: null is handled implicitly below by `!params.job` being truthy.
          if (params.job != null && typeof params.job !== "object") {
            params.job = undefined;
          }
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(cron): recover flat schedule params ..." | Re-trigger Greptile

Comment on lines +363 to +386
// Reconstruct schedule from shorthand keys when not already present.
if (!synthetic.schedule) {
const everyMs =
typeof synthetic.everyMs === "number"
? synthetic.everyMs
: typeof synthetic.every === "number"
? synthetic.every
: undefined;
if (typeof everyMs === "number" && everyMs > 0) {
synthetic.schedule = { kind: "every", everyMs };
} else if (typeof synthetic.cron === "string" && synthetic.cron.trim()) {
synthetic.schedule = {
kind: "cron",
expr: synthetic.cron.trim(),
...(typeof synthetic.tz === "string" && synthetic.tz.trim()
? { tz: synthetic.tz.trim() }
: {}),
};
} else if (synthetic.at !== undefined) {
const atValue =
typeof synthetic.at === "string" ? synthetic.at : JSON.stringify(synthetic.at);
synthetic.schedule = { kind: "at", at: atValue };
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 staggerMs silently dropped after cron-shorthand reconstruction

staggerMs is now captured in JOB_KEYS (signalling that LLMs may send it as a flat param), but the cron shorthand reconstruction only spreads tz into the synthetic schedule — staggerMs is never added. The cleanup loop then deletes it, so a flat call like { cron: "0 0 * * *", staggerMs: 5000, message: "ping" } produces { kind: "cron", expr: "0 0 * * *" } with staggerMs silently discarded.

The same gap exists in the update code path (the duplicate reconstruction block around lines 556-600).

Consider spreading staggerMs into the cron schedule when present:

} else if (typeof synthetic.cron === "string" && synthetic.cron.trim()) {
  synthetic.schedule = {
    kind: "cron",
    expr: synthetic.cron.trim(),
    ...(typeof synthetic.tz === "string" && synthetic.tz.trim()
      ? { tz: synthetic.tz.trim() }
      : {}),
    ...(typeof synthetic.staggerMs === "number" ? { staggerMs: synthetic.staggerMs } : {}),
  };
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/tools/cron-tool.ts
Line: 363-386

Comment:
**`staggerMs` silently dropped after cron-shorthand reconstruction**

`staggerMs` is now captured in `JOB_KEYS` (signalling that LLMs may send it as a flat param), but the `cron` shorthand reconstruction only spreads `tz` into the synthetic schedule — `staggerMs` is never added. The cleanup loop then deletes it, so a flat call like `{ cron: "0 0 * * *", staggerMs: 5000, message: "ping" }` produces `{ kind: "cron", expr: "0 0 * * *" }` with `staggerMs` silently discarded.

The same gap exists in the `update` code path (the duplicate reconstruction block around lines 556-600).

Consider spreading `staggerMs` into the cron schedule when present:

```typescript
} else if (typeof synthetic.cron === "string" && synthetic.cron.trim()) {
  synthetic.schedule = {
    kind: "cron",
    expr: synthetic.cron.trim(),
    ...(typeof synthetic.tz === "string" && synthetic.tz.trim()
      ? { tz: synthetic.tz.trim() }
      : {}),
    ...(typeof synthetic.staggerMs === "number" ? { staggerMs: synthetic.staggerMs } : {}),
  };
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/agents/tools/cron-tool.ts Outdated
Comment on lines +308 to +312
// Coerce non-object job values (null, string) to undefined so flat-params
// recovery triggers instead of failing schema validation (#56996).
if (params.job != null && typeof params.job !== "object") {
params.job = undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Misleading comment — null is not coerced here

The comment says "Coerce non-object job values (null, string) to undefined", but because typeof null === "object" in JavaScript, the condition params.job != null && typeof params.job !== "object" evaluates as:

  • null: null != nullfalse → the branch is never entered; params.job stays null
  • "some string": both checks pass → correctly coerced to undefined

null still works because the immediately-following !params.job check is truthy for null, so flat-params recovery does trigger. But the comment and the test name ("coerces null job to undefined") misrepresent what the code actually does, which could mislead future readers.

Suggested change
// Coerce non-object job values (null, string) to undefined so flat-params
// recovery triggers instead of failing schema validation (#56996).
if (params.job != null && typeof params.job !== "object") {
params.job = undefined;
}
// Coerce non-null non-object job values (e.g. a bare string) to undefined so
// flat-params recovery triggers instead of failing schema validation (#56996).
// Note: null is handled implicitly below by `!params.job` being truthy.
if (params.job != null && typeof params.job !== "object") {
params.job = undefined;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/tools/cron-tool.ts
Line: 308-312

Comment:
**Misleading comment — `null` is not coerced here**

The comment says "Coerce non-object job values **(null**, string) to undefined", but because `typeof null === "object"` in JavaScript, the condition `params.job != null && typeof params.job !== "object"` evaluates as:

- `null`: `null != null``false` → the branch is **never entered**; `params.job` stays `null`
- `"some string"`: both checks pass → correctly coerced to `undefined``null` still works because the immediately-following `!params.job` check is truthy for `null`, so flat-params recovery does trigger. But the comment and the test name (`"coerces null job to undefined"`) misrepresent what the code actually does, which could mislead future readers.

```suggestion
          // Coerce non-null non-object job values (e.g. a bare string) to undefined so
          // flat-params recovery triggers instead of failing schema validation (#56996).
          // Note: null is handled implicitly below by `!params.job` being truthy.
          if (params.job != null && typeof params.job !== "object") {
            params.job = undefined;
          }
```

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a10e57810

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +310 to +312
if (params.job != null && typeof params.job !== "object") {
params.job = undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Coerce array-valued job inputs before recovery

Because job now uses a fully permissive schema, arrays are accepted at the tool boundary, but this guard only rewrites non-object primitives. In the add flow, a non-empty array like job: [{...}] skips flat-params recovery, normalizeCronJobCreate returns null, and the ?? params.job fallback forwards the array to cron.add, causing downstream validation failure instead of recovering or failing fast with a clear error. The same pattern exists for patch in update, so array inputs should be treated as invalid wrappers (like strings/null) before normalization.

Useful? React with 👍 / 👎.

@tyxben tyxben force-pushed the fix/cron-tool-flat-params-56996 branch from 6a10e57 to aaebd7f Compare April 2, 2026 04:59

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aaebd7f19a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +383 to +385
...(typeof synthetic.staggerMs === "number"
? { staggerMs: synthetic.staggerMs }
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve exact/stagger shorthand when rebuilding cron schedules

In flat-params recovery, cron schedules only carry staggerMs when it is already a number, and then the code deletes exact/stagger/staggerMs from the synthetic payload. That means inputs like cron: "0 * * * *", exact: true or staggerMs: "0" (which normalizeCronStaggerMs otherwise accepts) lose the explicit stagger override and fall back to default top-of-hour staggering, changing when jobs run. Please translate exact/string staggerMs/stagger into schedule.staggerMs before the cleanup step (the same issue is duplicated in the update recovery block).

Useful? React with 👍 / 👎.

@vincentkoc

Copy link
Copy Markdown
Member

This is related to the legacy cron shape cleanup, but I’m leaving it open as a separate track.

#71534 fixed persisted legacy jobs.json rows that had flat cron/tz/session fields. This PR is about agent tool calls sending flat cron params into cron-tool.ts, including schedule shorthand recovery and flat-param validation. That is a different input boundary, so it should not be closed as a duplicate of the store-load fix.

Next useful step here is resolving the existing review comments, especially preserving flat staggerMs during cron shorthand reconstruction.

@vincentkoc

Copy link
Copy Markdown
Member

Thanks for the useful flat-schedule recovery work here. I reworked the safe subset into #71547 and merged it to main as 9242713.

That version preserves flat cron/tz/staggerMs add/update calls before gateway validation and also resolves the remaining cron restart interruption issues in the same cleanup pass. Closing this PR as superseded so we do not keep two competing cron-tool fixes open. If there is a separate non-object coercion case still reproducible on current main, please reopen with the exact payload and we can route it as a focused follow-up.

@vincentkoc vincentkoc closed this Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling dedupe:parent Primary canonical item in dedupe cluster size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] cron add CLI validation failed: job must be object

2 participants