fix(cron): recover flat params when LLM omits job wrapper (#12124)

* fix(cron): recover flat params when LLM omits job wrapper (#11310)

Non-frontier models (e.g. Grok) flatten job properties to the top level
alongside `action` instead of nesting them inside the `job` parameter.
The opaque schema (`Type.Object({}, { additionalProperties: true })`)
gives these models no structural hint, so they put name, schedule,
payload, etc. as siblings of action.

Add a flat-params recovery step in the cron add handler: when
`params.job` is missing or an empty object, scan for recognised job
property names on params and construct a synthetic job object before
passing to `normalizeCronJobCreate`. Recovery requires at least one
meaningful signal field (schedule, payload, message, or text) to avoid
false positives.

Added tests:
- Flat params with no job wrapper → recovered
- Empty job object + flat params → recovered
- Message shorthand at top level → inferred as agentTurn
- No meaningful fields → still throws 'job required'
- Non-empty job takes precedence over flat params

* fix(cron): floor nowMs to second boundary before croner lookback

Cron expressions operate at second granularity. When nowMs falls
mid-second (e.g. 12:00:00.500) and the pattern targets that exact
second (like '0 0 12 * * *'), a 1ms lookback still lands inside the
matching second.  Croner interprets this as 'already past' and skips
to the next occurrence (e.g. the following day).

Fix: floor nowMs to the start of the current second before applying
the 1ms lookback.  This ensures the reference always falls in the
*previous* second, so croner correctly identifies the current match.

Also compare the result against the floored nowSecondMs (not raw nowMs)
so that a match at the start of the current second is not rejected by
the >= guard when nowMs has sub-second offset.

Adds regression tests for 6-field cron patterns with specific seconds.

* fix: add changelog entries for cron fixes (#12124) (thanks @tyler6204)

* test: stabilize warning filter emit assertion (#12124) (thanks @tyler6204)
This commit is contained in:
Tyler Yust
2026-02-08 23:10:09 -08:00
committed by GitHub
parent fb8e4489a3
commit 07375a65d8
6 changed files with 202 additions and 8 deletions

View File

@@ -33,4 +33,38 @@ describe("cron schedule", () => {
const next = computeNextRunAtMs({ kind: "every", everyMs: 30_000, anchorMs: anchor }, anchor);
expect(next).toBe(anchor + 30_000);
});
describe("cron with specific seconds (6-field pattern)", () => {
// Pattern: fire at exactly second 0 of minute 0 of hour 12 every day
const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" };
const noonMs = Date.parse("2026-02-08T12:00:00.000Z");
it("returns current occurrence when nowMs is exactly at the match", () => {
const next = computeNextRunAtMs(dailyNoon, noonMs);
expect(next).toBe(noonMs);
});
it("returns current occurrence when nowMs is mid-second (.500) within the match", () => {
// This is the core regression: without the second-floor fix, a 1ms
// lookback from 12:00:00.499 still lands inside the matching second,
// causing croner to skip to the *next day*.
const next = computeNextRunAtMs(dailyNoon, noonMs + 500);
expect(next).toBe(noonMs);
});
it("returns current occurrence when nowMs is late in the matching second (.999)", () => {
const next = computeNextRunAtMs(dailyNoon, noonMs + 999);
expect(next).toBe(noonMs);
});
it("advances to next day once the matching second is fully past", () => {
const next = computeNextRunAtMs(dailyNoon, noonMs + 1000);
expect(next).toBe(noonMs + 86_400_000); // next day
});
it("returns today when nowMs is before the match", () => {
const next = computeNextRunAtMs(dailyNoon, noonMs - 500);
expect(next).toBe(noonMs);
});
});
});

View File

@@ -49,13 +49,17 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
timezone: resolveCronTimezone(schedule.tz),
catch: false,
});
// Use a tiny lookback (1ms) so croner doesn't skip the current second
// boundary. Without this, a job updated at exactly its cron time would
// be scheduled for the *next* matching time (e.g. 24h later for daily).
const next = cron.nextRun(new Date(nowMs - 1));
// Cron operates at second granularity, so floor nowMs to the start of the
// current second. This prevents the lookback from landing inside a matching
// second — if nowMs is e.g. 12:00:00.500 and the pattern fires at second 0,
// a 1ms lookback (12:00:00.499) is still *within* that second, causing
// croner to skip ahead to the next occurrence (e.g. the following day).
// Flooring first ensures the lookback always falls in the *previous* second.
const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
const next = cron.nextRun(new Date(nowSecondMs - 1));
if (!next) {
return undefined;
}
const nextMs = next.getTime();
return Number.isFinite(nextMs) && nextMs >= nowMs ? nextMs : undefined;
return Number.isFinite(nextMs) && nextMs >= nowSecondMs ? nextMs : undefined;
}