Skip to content

Commit 6a00c36

Browse files
vincentkocamersheeny
authored andcommitted
fix(cron): protect schedule activation ownership
1 parent a5755cb commit 6a00c36

5 files changed

Lines changed: 57 additions & 11 deletions

File tree

packages/gateway-protocol/src/schema/cron.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,6 @@ const CronJobStatePatchSchema = Type.Object(
410410
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
411411
runningAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
412412
lastRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
413-
scheduleActivatedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
414413
lastRunStatus: Type.Optional(CronRunStatusSchema),
415414
lastStatus: Type.Optional(DeprecatedCronRunStatusSchema),
416415
lastError: Type.Optional(Type.String()),

src/cron/cron-protocol-schema.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Cron protocol schema tests cover runtime validation for cron protocol payloads.
22
import { describe, expect, it } from "vitest";
3-
import { CronJobStateSchema } from "../../packages/gateway-protocol/src/schema.js";
3+
import {
4+
CronJobPatchSchema,
5+
CronJobStateSchema,
6+
} from "../../packages/gateway-protocol/src/schema.js";
47

58
type SchemaLike = {
69
properties?: Record<string, unknown>;
710
deprecated?: boolean;
11+
additionalProperties?: boolean;
812
};
913

1014
describe("cron protocol schema", () => {
@@ -30,4 +34,11 @@ describe("cron protocol schema", () => {
3034
const properties = (CronJobStateSchema as SchemaLike).properties ?? {};
3135
expect(properties.scheduleActivatedAtMs).toBeDefined();
3236
});
37+
38+
it("rejects schedule activation timestamps in public state patches", () => {
39+
const patchProperties = (CronJobPatchSchema as SchemaLike).properties ?? {};
40+
const stateSchema = patchProperties.state as SchemaLike | undefined;
41+
expect(stateSchema?.additionalProperties).toBe(false);
42+
expect(stateSchema?.properties?.scheduleActivatedAtMs).toBeUndefined();
43+
});
3344
});

src/cron/service.jobs.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
recomputeNextRunsForMaintenance,
99
} from "./service/jobs.js";
1010
import type { CronServiceState } from "./service/state.js";
11-
import type { CronJob, CronJobPatch } from "./types.js";
11+
import type { CronJob, CronJobCreate, CronJobPatch } from "./types.js";
1212

1313
const DEFAULT_TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1000;
1414

@@ -67,6 +67,19 @@ describe("applyJobPatch", () => {
6767
expect(job.delivery).toBeUndefined();
6868
});
6969

70+
it("ignores caller-supplied schedule activation state", () => {
71+
const job = createIsolatedAgentTurnJob("job-activation-owner", undefined, {
72+
state: { scheduleActivatedAtMs: 456 },
73+
});
74+
const patch = {
75+
state: { scheduleActivatedAtMs: 123 },
76+
} as unknown as CronJobPatch;
77+
78+
applyJobPatch(job, patch);
79+
80+
expect(job.state.scheduleActivatedAtMs).toBe(456);
81+
});
82+
7083
it("keeps webhook delivery when switching to main session", () => {
7184
const job = createIsolatedAgentTurnJob("job-webhook", {
7285
mode: "webhook",
@@ -597,6 +610,26 @@ function createMockState(now: number, opts?: { defaultAgentId?: string }): CronS
597610
} as unknown as CronServiceState;
598611
}
599612

613+
describe("createJob schedule activation ownership", () => {
614+
it("uses the service clock instead of caller-supplied state", () => {
615+
const now = Date.parse("2026-06-16T06:00:00.000Z");
616+
const state = createMockState(now);
617+
const input = {
618+
name: "activation-owned",
619+
enabled: true,
620+
schedule: { kind: "cron" as const, expr: "0 * * * *", tz: "UTC" },
621+
sessionTarget: "main" as const,
622+
wakeMode: "now" as const,
623+
payload: { kind: "systemEvent" as const, text: "tick" },
624+
state: { scheduleActivatedAtMs: now - 60_000 },
625+
} as unknown as CronJobCreate;
626+
627+
const job = createJob(state, input);
628+
629+
expect(job.state.scheduleActivatedAtMs).toBe(now);
630+
});
631+
});
632+
600633
describe("createJob rejects sessionTarget main for non-default agents", () => {
601634
const now = Date.parse("2026-02-28T12:00:00.000Z");
602635

src/cron/service/jobs.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
CronJob,
2525
CronJobCreate,
2626
CronJobPatch,
27+
CronJobState,
2728
CronPayload,
2829
CronPayloadPatch,
2930
} from "../types.js";
@@ -794,6 +795,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
794795
failureAlert: input.failureAlert,
795796
state: {
796797
...input.state,
798+
scheduleActivatedAtMs: now,
797799
},
798800
};
799801
assertSupportedJobSpec(job);
@@ -802,11 +804,6 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
802804
assertFailureDestinationSupport(job);
803805
assertCronExpressionSatisfiable(job, now);
804806
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
805-
// Creation activates the schedule; absent an explicit caller value, anchor
806-
// catch-up's "predates the schedule" boundary at creation time (#91944).
807-
if (!isFiniteTimestamp(job.state.scheduleActivatedAtMs)) {
808-
job.state.scheduleActivatedAtMs = now;
809-
}
810807
return job;
811808
}
812809

@@ -882,7 +879,11 @@ export function applyJobPatch(
882879
: undefined;
883880
}
884881
if (patch.state) {
885-
job.state = { ...job.state, ...patch.state };
882+
const statePatch = { ...patch.state } as Partial<CronJobState>;
883+
// The scheduler owns this boundary; public state patches must not move it
884+
// backward or forward and change restart catch-up behavior (#91944).
885+
delete statePatch.scheduleActivatedAtMs;
886+
job.state = { ...job.state, ...statePatch };
886887
}
887888
if ("agentId" in patch) {
888889
job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId);

src/cron/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,11 @@ export type CronStoreFile = {
343343
jobs: CronJob[];
344344
};
345345

346+
type CronJobCallerState = Omit<CronJobState, "scheduleActivatedAtMs">;
347+
346348
/** Create input accepted by cron APIs before id/timestamps/state are assigned. */
347349
export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" | "state"> & {
348-
state?: Partial<CronJobState>;
350+
state?: Partial<CronJobCallerState>;
349351
};
350352

351353
/** Patch input accepted by cron APIs without allowing immutable identity fields. */
@@ -354,5 +356,5 @@ export type CronJobPatch = Partial<
354356
> & {
355357
payload?: CronPayloadPatch;
356358
delivery?: CronDeliveryPatch;
357-
state?: Partial<CronJobState>;
359+
state?: Partial<CronJobCallerState>;
358360
};

0 commit comments

Comments
 (0)