Files
openclaw/src/cron/service/ops.ts
T
Peter Steinberger 4e9ae9fbff feat(cron): system-owned heartbeat monitor jobs replace the dedicated interval scheduler (#112585)
* feat(cron): system-owned heartbeat monitor jobs replace the interval scheduler

- new internal cron payload kind {kind:"heartbeat"}: execution pokes
  requestHeartbeat({source:"interval"}); reported in the protocol job
  schema, not accepted from client create/patch
- gateway converges one declaration-keyed monitor job per heartbeat-enabled
  agent (schedule every+deterministic phase anchor) at startup and on
  config reload; removes monitors for unconfigured agents
- heartbeat runner loses its interval setTimeout machinery; nextDueMs
  stays as the cooldown gate, event wakes unchanged

* test(cron): heartbeat monitor regressions; docs for cron-owned cadence

- converge/prune/failure-containment tests for heartbeat monitor jobs
- heartbeat payload run fires an interval wake, no system event
- scheduler tests converted from timer self-fire to wake-queue pokes;
  timer-mechanics-only tests deleted with the timer
- persisted-shape accepts the heartbeat payload kind
- docs: heartbeat cadence ownership + system payload kind

* fix(cron): heartbeat monitor review round 1

- targeted cron-monitor interval ticks use the full per-agent path so
  due-commitment sessions still deliver
- cron-disabled gateways keep a local fallback interval timer (shipped
  cron.enabled=false contract; removed when heartbeat config folds into
  cron in #110950)
- heartbeat job reconciliations serialize with latest-wins epochs and a
  bounded 30s retry after a failed convergence pass

* fix(cron): chain clamped fallback heartbeat timers past the setTimeout cap

* fix(cron): heartbeat monitor review round 3

- targeted monitor redirect skips wakes carrying heartbeat overrides and
  surfaces the per-agent terminal skip reason instead of not-due
- cron-disabled fallback timer re-arms with a 1s floor after each firing
  so a dropped wake cannot end the chain
- heartbeat payloads are system-owned at the service boundary: add requires
  the gateway opt-in, patches to the kind are rejected

* fix(cron): heartbeat monitor review round 4 — full ownership enforcement

- prune only jobs proven to be monitors (prefix AND heartbeat payload)
- existing monitors reject every update patch; declarative upserts on the
  monitor key require the gateway opt-in even with a different payload

* fix(cron): complete heartbeat monitor ownership boundary

- converge scopes declarative matching to real monitors so a colliding
  user job with the same key is never adopted or overwritten
- monitor removal requires the gateway systemOwned opt-in; ad-hoc
  API/CLI deletion is rejected, reconciliation cleanup still prunes

* docs(cron): record intentional enrollment-snapshot semantics for monitor ticks

* fix(cron): repair heartbeat monitor CI gates
2026-07-22 14:03:29 -07:00

1970 lines
67 KiB
TypeScript

/** Public cron service operations for lifecycle, CRUD, listing, and manual runs. */
import { isDeepStrictEqual } from "node:util";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
AgentDeletionAuthorityRollbackError,
AgentDeletionCommitUncertainError,
} from "../../agents/agent-lifecycle-registry.js";
import { enqueueCommandInLane, type CommandLaneTaskMarker } from "../../process/command-queue.js";
import { runWithGatewayIndependentRootWorkContinuation } from "../../process/gateway-work-admission.js";
import { CommandLane } from "../../process/lanes.js";
import { DEFAULT_AGENT_ID } from "../../routing/session-key.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import {
clearCronJobActive,
isCronActiveJobMarkerCurrent,
isCronJobActive,
markCronJobActive,
type CronActiveJobMarker,
} from "../active-jobs.js";
import { resolveCronListSnapshotRevision } from "../list-snapshot-revision.js";
import { createCronRunDiagnosticsFromError } from "../run-diagnostics.js";
import { cronSchedulingInputsEqual } from "../schedule-identity.js";
import { createCronStreamSourceIdentity, cronStreamScheduleKey } from "../stream-schedule.js";
import { normalizeCronTaskRunJobId } from "../task-run-history.js";
import type {
CronJob,
CronJobCreate,
CronJobPatch,
CronPayload,
CronRunErrorClassification,
} from "../types.js";
import { normalizeCronRunErrorText } from "./execution-errors.js";
import { failureNotificationDeliveryFromJobState } from "./failure-alerts.js";
import {
applyJobPatch,
applyDeclarativeJobSpec,
assertSupportedJobSpec,
computeJobNextRunAtMs,
createJob,
findJobOrThrow,
hasActiveCronRun,
hasScheduledNextRunAtMs,
isJobEnabled,
isJobDue,
nextWakeAtMs,
recomputeNextRunsForMaintenance,
} from "./jobs.js";
import { sortCronJobs } from "./list-page-sort.js";
import type {
CronJobsEnabledFilter,
CronJobsLastRunStatusFilter,
CronJobsScheduleKindFilter,
CronListPageOptions,
CronListPageResult,
} from "./list-page-types.js";
import { locked } from "./locked.js";
import { normalizeOptionalAgentId } from "./normalize.js";
import {
cancelCronRunAdmissionWaiters,
clearQueuedCronRunReservationMarker,
isQueuedCronRunReservationCurrent,
isQueuedCronRunReservationMarkerCurrent,
releaseQueuedCronRun,
reserveQueuedCronRun,
runWithCronAdmission,
updateQueuedCronRunReservationMarker,
} from "./run-admission.js";
import {
type InterruptedStartupRun,
markInterruptedStartupRun,
mergeManualRunSnapshotAfterReload,
restoreFinalizedStartupRun,
STARTUP_INTERRUPTED_ERROR,
} from "./startup-run-repair.js";
import type {
CronAddOptions,
CronEvent,
CronServiceState,
CronUpdatePrecondition,
CronWakeMode,
} from "./state.js";
import { emit } from "./state.js";
import {
ensureLoaded,
persist,
persistOrRestore,
snapshotStoreForRollback,
type CronRollbackSnapshot,
warnIfDisabled,
} from "./store.js";
import {
tryCreateCronTaskRun,
tryFindCronTaskRunIdForRecovery,
tryFindFinalizedCronTaskRun,
tryFinishCronTaskRun,
tryFinishCronTaskRunWithoutHistory,
} from "./task-runs.js";
import {
applyJobResult,
applyScriptRunResult,
applyTriggerNoFireResult,
applyTriggerRunResult,
armTimer,
type CronTriggerEvalOutcome,
executeJobCoreWithTimeout,
type IsolatedAgentSetupTimeoutSignal,
maybeNotifyIsolatedAgentSetupTimeout,
runMissedJobs,
runsDetachedFromMainSession,
stopTimer,
} from "./timer.js";
import { wake } from "./wake.js";
function markManualCronJobActive(
state: CronServiceState,
job: CronJob,
): CronActiveJobMarker | undefined {
const jobId = job.id;
state.activeManualRunJobIds.add(jobId);
return markCronJobActive(jobId, {
preserveAcrossGenerationAdvance: !runsDetachedFromMainSession(job),
});
}
function clearManualCronJobActive(
state: CronServiceState,
jobId: string,
activeJobMarker?: CronActiveJobMarker,
): void {
state.activeManualRunJobIds.delete(jobId);
clearCronJobActive(jobId, activeJobMarker);
if (state.activeManualRunJobIds.size === 0) {
state.manualSetupTimeoutNotified = false;
}
}
function maybeNotifyManualIsolatedSetupTimeout(
state: CronServiceState,
result: {
jobId: string;
job: CronJob;
isolatedAgentSetupTimeout?: IsolatedAgentSetupTimeoutSignal;
},
): boolean {
if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutNotified) {
return false;
}
const notified = maybeNotifyIsolatedAgentSetupTimeout(state, result);
state.manualSetupTimeoutNotified ||= notified;
return notified;
}
async function ensureLoadedForRead(state: CronServiceState) {
await ensureLoaded(state, { skipRecompute: true });
if (!state.store) {
return;
}
// Use the maintenance-only version so that read-only operations never
// advance a past-due nextRunAtMs without executing the job (#16156).
const changed = recomputeNextRunsForMaintenance(state);
if (changed) {
await persist(state);
}
}
/** Starts the cron service, recovers interrupted runs, catches up missed jobs, and arms the timer. */
export async function start(state: CronServiceState) {
state.stopped = false;
if (!state.deps.cronEnabled) {
state.deps.log.info({ enabled: false }, "cron: disabled");
return;
}
const interruptedJobIds = new Set<string>();
const interruptedRuns: InterruptedStartupRun[] = [];
const completedJobIdsToDelete = new Set<string>();
let repairedAnyStartupRun = false;
await locked(state, async () => {
await ensureLoaded(state, { skipRecompute: true });
if (state.stopped) {
return;
}
const jobs = state.store?.jobs ?? [];
for (const job of jobs) {
job.state ??= {};
if (typeof job.state.queuedAtMs === "number") {
state.deps.log.info(
{ jobId: job.id, queuedAtMs: job.state.queuedAtMs },
"cron: releasing queued job reservation on startup",
);
job.state.queuedAtMs = undefined;
repairedAnyStartupRun = true;
}
if (typeof job.state.runningAtMs === "number") {
// Older releases used runningAtMs for both queued and active work. Those
// rows are intentionally recovered conservatively to avoid replaying side effects.
const runningAtMs = job.state.runningAtMs;
const taskRunId = tryFindCronTaskRunIdForRecovery(state, job.id, runningAtMs);
const finalized = tryFindFinalizedCronTaskRun(state, job.id, runningAtMs);
if (finalized) {
interruptedJobIds.add(job.id);
if (
restoreFinalizedStartupRun({
state,
job,
runningAtMs,
entry: finalized.entry,
...(finalized.scriptResult ? { scriptResult: finalized.scriptResult } : {}),
...(finalized.triggerEval ? { triggerEval: finalized.triggerEval } : {}),
})
) {
completedJobIdsToDelete.add(job.id);
}
repairedAnyStartupRun = true;
continue;
}
const nowMs = state.deps.nowMs();
const interrupted = markInterruptedStartupRun({
state,
job,
taskRunId,
runningAtMs,
nowMs,
});
interruptedJobIds.add(job.id);
interruptedRuns.push(interrupted);
repairedAnyStartupRun = true;
}
}
if (completedJobIdsToDelete.size > 0 && state.store) {
state.store.jobs = jobs.filter((job) => !completedJobIdsToDelete.has(job.id));
}
if (repairedAnyStartupRun || jobs.length > 0) {
await persist(state, repairedAnyStartupRun ? undefined : { stateOnly: true });
}
});
if (state.stopped) {
return;
}
await runMissedJobs(state, {
skipJobIds: interruptedJobIds.size > 0 ? interruptedJobIds : undefined,
deferAgentTurnJobs: true,
});
await locked(state, async () => {
// Startup catch-up already persisted the latest in-memory store state, and
// this path runs before the scheduler begins servicing regular timer ticks.
// Avoid an extra reload/write cycle on startup.
await ensureLoaded(state, { skipRecompute: true });
if (state.stopped) {
return;
}
const changed = recomputeNextRunsForMaintenance(state, { recomputeExpired: true });
if (changed) {
await persist(state);
}
for (const interrupted of interruptedRuns) {
const job = state.store?.jobs.find((entry) => entry.id === interrupted.jobId);
emitCronRunFinished(
state,
{
jobId: interrupted.jobId,
action: "finished",
job,
status: "error",
error: STARTUP_INTERRUPTED_ERROR,
delivered: false,
deliveryStatus: "unknown",
deliveryError: STARTUP_INTERRUPTED_ERROR,
failureNotificationDelivery: job
? failureNotificationDeliveryFromJobState(job)
: undefined,
runAtMs: interrupted.runAtMs,
durationMs: interrupted.durationMs,
nextRunAtMs: job?.state.nextRunAtMs,
},
undefined,
interrupted.taskRunId,
);
}
armTimer(state);
state.deps.log.info(
{
enabled: true,
jobs: state.store?.jobs.length ?? 0,
nextWakeAtMs: nextWakeAtMs(state) ?? null,
},
"cron: started",
);
});
}
/** Stops the cron service timer without mutating persisted job state. */
export function stop(state: CronServiceState) {
state.stopped = true;
cancelCronRunAdmissionWaiters(state);
state.schedulerStarted = false;
stopTimer(state);
}
/** Temporarily stops automatic ticks without running startup recovery on resume. */
export function pauseScheduling(state: CronServiceState) {
state.schedulingPaused = true;
stopTimer(state);
}
export function resumeScheduling(state: CronServiceState) {
if (!state.schedulingPaused) {
return;
}
state.schedulingPaused = false;
if (!state.schedulerStarted) {
return;
}
try {
armTimer(state);
} catch (err) {
// armTimer can install a timer before a later dependency throws. Roll the
// whole transition back so a suspension retry cannot reopen without cron.
state.schedulingPaused = true;
stopTimer(state);
throw err;
}
}
/** Returns cron service status after a read-only maintenance pass. */
export async function status(state: CronServiceState) {
return await locked(state, async () => {
await ensureLoadedForRead(state);
const sqlitePath = resolveOpenClawStateSqlitePath();
return {
enabled: state.deps.cronEnabled,
storePath: sqlitePath,
storage: "sqlite" as const,
sqlitePath,
jobs: state.store?.jobs.length ?? 0,
nextWakeAtMs: state.deps.cronEnabled ? (nextWakeAtMs(state) ?? null) : null,
};
});
}
/** Lists cron jobs sorted by next run time, excluding disabled jobs unless requested. */
export async function list(state: CronServiceState, opts?: { includeDisabled?: boolean }) {
return await locked(state, async () => {
await ensureLoadedForRead(state);
const includeDisabled = opts?.includeDisabled === true;
const jobs = (state.store?.jobs ?? []).filter((j) => includeDisabled || isJobEnabled(j));
return jobs.toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0));
});
}
/** Reads one cron job by id without advancing due schedules. */
export async function readJob(state: CronServiceState, id: string) {
return await locked(state, async () => {
await ensureLoadedForRead(state);
return state.store?.jobs.find((job) => job.id === id);
});
}
/** Record a terminal failure from a scheduler-owned event source. */
export async function recordExternalFailure(
state: CronServiceState,
id: string,
error: string,
statePatch: Partial<CronJob["state"]>,
source?: { scheduleKey: string; identity: string },
) {
await locked(state, async () => {
await ensureLoaded(state, { skipRecompute: true });
const job = findJobOrThrow(state, id);
if (source && !ownsStreamSource(job, source.scheduleKey, source.identity)) {
return;
}
const snapshot = snapshotStoreForRollback(state);
const now = state.deps.nowMs();
const sourceIdentity = job.state.streamSourceIdentity;
Object.assign(job.state, statePatch);
job.state.streamSourceIdentity = sourceIdentity;
// Source restarts are counted separately, but terminal exhaustion should
// enter the same alert/history path as a fifth consecutive payload error.
job.state.consecutiveErrors = Math.max(job.state.consecutiveErrors ?? 0, 4);
applyJobResult(state, job, {
status: "error",
error,
executionStarted: false,
startedAt: now,
endedAt: now,
});
// Stream schedules are event-driven; applyJobResult's generic recurring
// backoff must never turn source failure into a time-due payload run.
job.state.nextRunAtMs = undefined;
emit(state, {
jobId: job.id,
action: "finished",
job,
status: "error",
error,
runAtMs: now,
durationMs: 0,
failureNotificationDelivery: failureNotificationDeliveryFromJobState(job),
});
await persistOrRestore(state, snapshot);
armTimer(state);
});
}
/** Atomically persist owner state only while its logical stream source still matches. */
export async function updateExternalState(
state: CronServiceState,
id: string,
streamScheduleKey: string,
streamSourceIdentity: string,
statePatch: Partial<CronJob["state"]>,
): Promise<boolean> {
return await locked(state, async () => {
await ensureLoaded(state, { skipRecompute: true });
const job = state.store?.jobs.find((entry) => entry.id === id);
if (!job || !ownsStreamSource(job, streamScheduleKey, streamSourceIdentity)) {
return false;
}
await updateLoadedJob({ state, id, patch: { state: statePatch } });
return true;
});
}
/** Retire a logical stream source before teardown that has no job-definition mutation. */
export async function retireExternalStreamSource(
state: CronServiceState,
id: string,
streamScheduleKey: string,
streamSourceIdentity: string,
): Promise<string | undefined> {
return await locked(state, async () => {
await ensureLoaded(state, { skipRecompute: true });
const job = state.store?.jobs.find((entry) => entry.id === id);
if (!job || !ownsStreamSource(job, streamScheduleKey, streamSourceIdentity)) {
return undefined;
}
const snapshot = snapshotStoreForRollback(state);
const nextIdentity = createCronStreamSourceIdentity();
job.state.streamSourceIdentity = nextIdentity;
await persistOrRestore(state, snapshot);
return nextIdentity;
});
}
/** Persist the owner's monotonic loss counters across stream schedule replacement. */
export async function updateExternalCounters(
state: CronServiceState,
id: string,
counters: Pick<CronJob["state"], "streamDroppedBatches" | "streamCoalescedBatches">,
): Promise<void> {
await locked(state, async () => {
await ensureLoaded(state, { skipRecompute: true });
const job = state.store?.jobs.find((entry) => entry.id === id);
// A retired owner's counter write can land after the job is converted to a
// non-stream schedule; only persist while the schedule is still stream so
// stream counters never bleed onto a time/cron job (applyJobPatch cleared
// them on conversion). Stream-to-stream replacements keep carrying counters.
if (!job || job.schedule.kind !== "stream") {
return;
}
await updateLoadedJob({
state,
id,
patch: {
state: {
streamDroppedBatches: Math.max(
job.state.streamDroppedBatches ?? 0,
counters.streamDroppedBatches ?? 0,
),
streamCoalescedBatches: Math.max(
job.state.streamCoalescedBatches ?? 0,
counters.streamCoalescedBatches ?? 0,
),
},
},
});
});
}
function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter {
if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") {
return opts.enabled;
}
return opts?.includeDisabled ? "all" : "enabled";
}
function resolveScheduleKindFilter(opts?: CronListPageOptions): CronJobsScheduleKindFilter {
if (
opts?.scheduleKind === "all" ||
opts?.scheduleKind === "at" ||
opts?.scheduleKind === "every" ||
opts?.scheduleKind === "cron" ||
opts?.scheduleKind === "on-exit" ||
opts?.scheduleKind === "stream"
) {
return opts.scheduleKind;
}
return "all";
}
function resolveLastRunStatusFilter(opts?: CronListPageOptions): CronJobsLastRunStatusFilter {
if (
opts?.lastRunStatus === "all" ||
opts?.lastRunStatus === "ok" ||
opts?.lastRunStatus === "error" ||
opts?.lastRunStatus === "skipped" ||
opts?.lastRunStatus === "unknown"
) {
return opts.lastRunStatus;
}
return "all";
}
function resolveJobLastRunStatus(job: CronJob): CronJobsLastRunStatusFilter {
return job.state.lastRunStatus ?? job.state.lastStatus ?? "unknown";
}
function resolveEffectiveJobAgentId(
job: { agentId?: string | null },
defaultAgentId: string | undefined,
) {
return (
normalizeOptionalAgentId(job.agentId) ??
normalizeOptionalAgentId(defaultAgentId) ??
DEFAULT_AGENT_ID
);
}
function resolveCurrentDefaultAgentId(state: CronServiceState): string | undefined {
return state.deps.resolveDefaultAgentId?.() ?? state.deps.defaultAgentId;
}
/** Lists a filtered, sorted, bounded page of cron jobs for CLI/RPC callers. */
export async function listPage(state: CronServiceState, opts?: CronListPageOptions) {
return await locked(state, async () => {
await ensureLoadedForRead(state);
const query = normalizeLowercaseStringOrEmpty(opts?.query);
const enabledFilter = resolveEnabledFilter(opts);
const scheduleKindFilter = resolveScheduleKindFilter(opts);
const lastRunStatusFilter = resolveLastRunStatusFilter(opts);
const sortBy = opts?.sortBy ?? "nextRunAtMs";
const sortDir = opts?.sortDir ?? "asc";
const requestedAgentId = normalizeOptionalAgentId(opts?.agentId);
const source = state.store?.jobs ?? [];
const filtered = source.filter((job) => {
if (enabledFilter === "enabled" && !isJobEnabled(job)) {
return false;
}
if (enabledFilter === "disabled" && isJobEnabled(job)) {
return false;
}
if (
requestedAgentId &&
resolveEffectiveJobAgentId(job, state.deps.defaultAgentId) !== requestedAgentId
) {
return false;
}
if (scheduleKindFilter !== "all" && job.schedule.kind !== scheduleKindFilter) {
return false;
}
if (lastRunStatusFilter !== "all" && resolveJobLastRunStatus(job) !== lastRunStatusFilter) {
return false;
}
if (!query) {
return true;
}
const haystack = normalizeLowercaseStringOrEmpty(
[job.id, job.name, job.description ?? "", job.agentId ?? ""].join(" "),
);
return haystack.includes(query);
});
// Execution mutates stored job state in place. Detach the complete result
// under the lock so every returned page still matches its revision later.
const snapshot = structuredClone(sortCronJobs(filtered, sortBy, sortDir));
const snapshotRevision = resolveCronListSnapshotRevision(snapshot);
const total = snapshot.length;
const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0)));
const defaultLimit = total === 0 ? 50 : total;
const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit)));
const jobs = snapshot.slice(offset, offset + limit);
const nextOffset = offset + jobs.length;
return {
jobs,
snapshotRevision,
total,
offset,
limit,
hasMore: nextOffset < total,
nextOffset: nextOffset < total ? nextOffset : null,
} satisfies CronListPageResult;
});
}
function reconcileStreamSourceIdentity(job: CronJob, nextJob: CronJob): void {
if (nextJob.schedule.kind !== "stream") {
nextJob.state.streamSourceIdentity = undefined;
return;
}
const sourceChanged =
job.schedule.kind !== "stream" ||
cronStreamScheduleKey(job.schedule) !== cronStreamScheduleKey(nextJob.schedule) ||
isJobEnabled(job) !== isJobEnabled(nextJob);
const currentIdentity =
job.schedule.kind === "stream" ? job.state.streamSourceIdentity : undefined;
nextJob.state.streamSourceIdentity =
sourceChanged || !currentIdentity ? createCronStreamSourceIdentity() : currentIdentity;
}
function finalizeUpdatedJob(params: {
job: CronJob;
nextJob: CronJob;
now: number;
schedulingInputsRequested: boolean;
scheduleChanged: boolean;
}) {
const { job, nextJob, now } = params;
if (nextJob.schedule.kind === "every") {
const anchor = nextJob.schedule.anchorMs;
if (typeof anchor !== "number" || !Number.isFinite(anchor)) {
// Inherit the previous cadence anchor only for an unchanged-interval
// re-save (UIs resubmit the schedule without the internal anchorMs).
// Without this an idempotent edit re-phases the job to now, shifting
// every future fire time and skipping an already-due slot. A genuine
// interval change still anchors to the edit time so the new cadence
// starts now, matching the prior update semantics.
const previousAnchorMs =
job.schedule.kind === "every" &&
job.schedule.everyMs === nextJob.schedule.everyMs &&
typeof job.schedule.anchorMs === "number" &&
Number.isFinite(job.schedule.anchorMs)
? job.schedule.anchorMs
: undefined;
const fallbackAnchorMs =
previousAnchorMs ??
(params.scheduleChanged
? now
: typeof nextJob.createdAtMs === "number" && Number.isFinite(nextJob.createdAtMs)
? nextJob.createdAtMs
: now);
nextJob.schedule = {
...nextJob.schedule,
anchorMs: Math.max(0, Math.floor(fallbackAnchorMs)),
};
}
}
// Source identity belongs to the durable job mutation, not the process
// watcher. Equivalent resaves preserve it; disable/enable and source changes
// rotate it in the same write that changes the public job definition.
reconcileStreamSourceIdentity(job, nextJob);
// Only advance a recurring job's next run when the schedule/enabled inputs
// actually changed. An idempotent re-save (same schedule, or re-enabling an
// already-enabled job) must preserve a still-due slot, matching the
// add/remove maintenance recompute; otherwise the pending run is dropped.
const schedulingInputsChanged =
params.schedulingInputsRequested && !cronSchedulingInputsEqual(job, nextJob);
if (params.scheduleChanged && nextJob.schedule.kind === "cron" && !isJobEnabled(nextJob)) {
computeJobNextRunAtMs({ ...nextJob, enabled: true }, now);
}
nextJob.updatedAtMs = now;
if (schedulingInputsChanged) {
nextJob.state.startupCatchupAtMs = undefined;
// A paced timestamp is owned by the exact schedule, pacing bounds, and
// trigger mode that produced it. Configuration changes release both the
// slot and its provenance so natural schedule math can take ownership.
nextJob.state.pacedNextRunAtMs = undefined;
nextJob.state.forcePreservedNextRunAtMs = undefined;
if (isJobEnabled(nextJob)) {
nextJob.state.nextRunAtMs = computeJobNextRunAtMs(nextJob, now);
} else {
nextJob.state.nextRunAtMs = undefined;
nextJob.state.queuedAtMs = undefined;
// Preserve only genuine execution. Queued reservations must clear so a
// disabled job can accept a later force run with the same timestamp.
if (!isCronJobActive(nextJob.id)) {
nextJob.state.runningAtMs = undefined;
}
}
} else if (isJobEnabled(nextJob) && !hasScheduledNextRunAtMs(nextJob.state.nextRunAtMs)) {
nextJob.state.nextRunAtMs = computeJobNextRunAtMs(nextJob, now);
}
}
async function persistUpdatedJob(params: {
state: CronServiceState;
snapshot: CronRollbackSnapshot;
nextJob: CronJob;
}) {
const { state, snapshot, nextJob } = params;
if (state.store) {
const index = state.store.jobs.findIndex((entry) => entry.id === nextJob.id);
if (index >= 0) {
state.store.jobs[index] = nextJob;
}
}
await persistOrRestore(state, snapshot, { suppressScheduledJobId: nextJob.id });
armTimer(state);
emit(state, {
jobId: nextJob.id,
action: "updated",
job: nextJob,
nextRunAtMs: nextJob.state.nextRunAtMs,
});
}
function declarativeFields(job: CronJob, includeEnabled: boolean) {
return {
schedule: job.schedule,
pacing: job.pacing,
trigger: job.trigger,
payload: job.payload,
delivery: job.delivery,
displayName: job.displayName,
...(includeEnabled ? { enabled: job.enabled } : {}),
};
}
/** Adds or converges a declaration-keyed cron job inside one store lock and write transaction. */
export async function add(state: CronServiceState, input: CronJobCreate, opts?: CronAddOptions) {
return await locked(state, async () => {
warnIfDisabled(state, "add");
// Heartbeat monitors are gateway-converged system jobs; without this
// boundary any internal caller could upsert the declaration key and
// hijack the monitor despite the transport schemas excluding the kind.
if (input.payload?.kind === "heartbeat" && opts?.systemOwned !== true) {
throw new Error("heartbeat payloads are system-owned; jobs cannot be created with them");
}
await ensureLoaded(state, { skipRecompute: true });
const agentId = resolveEffectiveJobAgentId(input, resolveCurrentDefaultAgentId(state));
if (state.deps.isAgentAvailable?.(agentId) === false) {
throw new Error(`cron job agent is unavailable: ${agentId}`);
}
const normalizedId = normalizeOptionalString(input.id);
if (input.id !== undefined && !normalizedId) {
throw new Error("cron job id must not be blank");
}
if (normalizedId) {
normalizeCronTaskRunJobId(normalizedId);
}
const normalizedInput = normalizedId ? { ...input, id: normalizedId } : input;
const declarationKey = normalizeOptionalString(input.declarationKey);
const matches = declarationKey
? (state.store?.jobs.filter(
(job) => job.declarationKey === declarationKey && (opts?.matchesExisting?.(job) ?? true),
) ?? [])
: [];
if (matches.length > 1) {
throw new Error(`cron declarationKey is ambiguous within caller scope: ${declarationKey}`);
}
const existing = matches[0];
if (existing) {
// A declarative upsert may not repurpose an existing heartbeat monitor
// with a different payload; only the gateway's own convergence touches it.
if (existing.payload.kind === "heartbeat" && opts?.systemOwned !== true) {
throw new Error(
"heartbeat monitor jobs are system-owned; edit agents.*.heartbeat config instead",
);
}
const now = state.deps.nowMs();
const nextJob = structuredClone(existing);
applyDeclarativeJobSpec(nextJob, normalizedInput, {
defaultAgentId: state.deps.defaultAgentId,
enabledExplicit: opts?.enabledExplicit === true,
nowMs: now,
cronConfig: state.deps.cronConfig,
});
const includeEnabled = opts?.enabledExplicit === true;
if (
isDeepStrictEqual(
declarativeFields(existing, includeEnabled),
declarativeFields(nextJob, includeEnabled),
)
) {
return { ...existing, created: false, updated: false, job: existing };
}
const snapshot = snapshotStoreForRollback(state);
finalizeUpdatedJob({
job: existing,
nextJob,
now,
schedulingInputsRequested: true,
scheduleChanged: !isDeepStrictEqual(existing.schedule, nextJob.schedule),
});
await persistUpdatedJob({ state, snapshot, nextJob });
return { ...nextJob, created: false, updated: true, job: nextJob };
}
if (normalizedId && state.store?.jobs.some((job) => job.id === normalizedId)) {
throw new Error(`cron job already exists: ${normalizedId}`);
}
const snapshot = snapshotStoreForRollback(state);
const job = createJob(state, normalizedInput);
state.store?.jobs.push(job);
// Auto-disable notifications describe durable state, so publish them only
// after the write succeeds instead of leaking a rolled-back transition.
const postPersistAutoDisableNotifications: Array<() => void> = [];
recomputeNextRunsForMaintenance(state, {
deferredAutoDisableNotifications: postPersistAutoDisableNotifications,
});
await persistOrRestore(state, snapshot, {
postPersistAutoDisableNotifications,
suppressScheduledJobId: job.id,
});
armTimer(state);
state.deps.log.info(
{
jobId: job.id,
jobName: job.name,
nextRunAtMs: job.state.nextRunAtMs,
schedulerNextWakeAtMs: nextWakeAtMs(state) ?? null,
timerArmed: state.timer !== null,
cronEnabled: state.deps.cronEnabled,
},
"cron: job added",
);
emit(state, {
jobId: job.id,
action: "added",
job,
nextRunAtMs: job.state.nextRunAtMs,
});
return declarationKey ? { ...job, created: true, job } : job;
});
}
async function updateLoadedJob(params: {
state: CronServiceState;
id: string;
patch: CronJobPatch;
precondition?: CronUpdatePrecondition;
}) {
const { state, id, patch, precondition } = params;
warnIfDisabled(state, "update");
// Mirrors the add-time boundary: no caller may patch a job into (or edit)
// the system-owned heartbeat payload; the gateway converges via add only.
if (patch.payload?.kind === "heartbeat") {
throw new Error("heartbeat payloads are system-owned; jobs cannot be patched to them");
}
await ensureLoaded(state, { skipRecompute: true });
const snapshot = snapshotStoreForRollback(state);
const job = findJobOrThrow(state, id);
// Existing monitors are config-driven: any patch (disable, reschedule,
// repurpose) would silently diverge from agents.*.heartbeat until the next
// reconcile, so updates are rejected outright. Removal stays allowed — a
// removed monitor self-heals at the next convergence.
if (job.payload.kind === "heartbeat") {
throw new Error(
"heartbeat monitor jobs are system-owned; edit agents.*.heartbeat config instead",
);
}
const now = state.deps.nowMs();
await precondition?.(structuredClone(job), now);
const nextJob = structuredClone(job);
applyJobPatch(nextJob, patch, {
defaultAgentId: state.deps.defaultAgentId,
scheduleValidationNowMs: now,
cronConfig: state.deps.cronConfig,
});
if (patch.agentId !== undefined) {
const agentId = resolveEffectiveJobAgentId(nextJob, resolveCurrentDefaultAgentId(state));
if (state.deps.isAgentAvailable?.(agentId) === false) {
throw new Error(`cron job agent is unavailable: ${agentId}`);
}
}
finalizeUpdatedJob({
job,
nextJob,
now,
schedulingInputsRequested:
patch.schedule !== undefined ||
patch.enabled !== undefined ||
"trigger" in patch ||
"pacing" in patch,
scheduleChanged: patch.schedule !== undefined,
});
await persistUpdatedJob({ state, snapshot, nextJob });
return nextJob;
}
/** Updates a cron job patch in-place, recomputes affected schedule state, and persists it. */
export async function update(state: CronServiceState, id: string, patch: CronJobPatch) {
return await locked(state, async () => await updateLoadedJob({ state, id, patch }));
}
/** Updates a cron job only after a store-locked caller precondition passes. */
export async function updateWithPrecondition(
state: CronServiceState,
id: string,
patch: CronJobPatch,
precondition: CronUpdatePrecondition,
) {
return await locked(state, async () => await updateLoadedJob({ state, id, patch, precondition }));
}
/** Removes a cron job by id and re-arms the timer when the in-memory store changes. */
export async function remove(
state: CronServiceState,
id: string,
opts?: { systemOwned?: boolean },
) {
return await locked(state, async () => {
warnIfDisabled(state, "remove");
await ensureLoaded(state, { skipRecompute: true });
const before = state.store?.jobs.length ?? 0;
if (!state.store) {
return { ok: false, removed: false } as const;
}
const snapshot = snapshotStoreForRollback(state);
const removedJob = state.store.jobs.find((j) => j.id === id);
// Config is the monitor's source of truth: ad-hoc deletion would disable
// heartbeats until an unrelated reload, so only gateway reconciliation
// (stale-monitor cleanup) may remove one.
if (removedJob?.payload.kind === "heartbeat" && opts?.systemOwned !== true) {
throw new Error(
"heartbeat monitor jobs are system-owned; edit agents.*.heartbeat config instead",
);
}
state.store.jobs = state.store.jobs.filter((j) => j.id !== id);
const removed = (state.store.jobs.length ?? 0) !== before;
const postPersistAutoDisableNotifications: Array<() => void> = [];
recomputeNextRunsForMaintenance(state, {
deferredAutoDisableNotifications: postPersistAutoDisableNotifications,
});
await persistOrRestore(state, snapshot, {
postPersistAutoDisableNotifications,
suppressScheduledJobId: id,
});
armTimer(state);
if (removed) {
emit(state, { jobId: id, action: "removed", job: removedJob });
}
return { ok: true, removed } as const;
});
}
/** Remove one agent's jobs while holding the cron lock across an external roster commit. */
export async function removeAgentJobsTransactional<T>(
state: CronServiceState,
agentId: string,
commit: () => Promise<T>,
): Promise<T> {
return await locked(state, async () => {
warnIfDisabled(state, "remove agent jobs");
await ensureLoaded(state, { skipRecompute: true });
const id = normalizeOptionalAgentId(agentId);
if (!id || !state.store) {
return await commit();
}
const defaultAgentId = resolveCurrentDefaultAgentId(state);
const removedJobs = state.store.jobs.filter(
(job) => resolveEffectiveJobAgentId(job, defaultAgentId) === id,
);
if (removedJobs.length === 0) {
return await commit();
}
const snapshot = snapshotStoreForRollback(state);
state.store.jobs = state.store.jobs.filter(
(job) => resolveEffectiveJobAgentId(job, defaultAgentId) !== id,
);
recomputeNextRunsForMaintenance(state);
await persistOrRestore(state, snapshot);
let result: T;
try {
result = await commit();
} catch (error) {
if (error instanceof AgentDeletionCommitUncertainError) {
armTimer(state);
for (const job of removedJobs) {
emit(state, { jobId: job.id, action: "removed", job });
}
throw error;
}
state.store = snapshot.store;
state.durableNextRunAtMsByJobId = snapshot.durableNextRunAtMsByJobId;
try {
if (!(await persist(state))) {
throw new Error("cron: rollback store write did not complete", { cause: error });
}
armTimer(state);
} catch (rollbackError) {
throw new AgentDeletionAuthorityRollbackError(
[error, rollbackError],
`cron: failed to roll back agent job deletion for ${id}`,
{ cause: error },
);
}
throw error;
}
armTimer(state);
for (const job of removedJobs) {
emit(state, { jobId: job.id, action: "removed", job });
}
return result;
});
}
type PreparedManualRun =
| {
ok: true;
ran: false;
reason:
| "already-running"
| "not-due"
| "invalid-spec"
| "restart-recovery-pending"
| "stopped";
}
| {
ok: true;
ran: true;
jobId: string;
runId?: string;
terminalTracker?: ManualRunTerminalTracker;
owningCronLaneTaskMarker?: CommandLaneTaskMarker;
reservationAt: number;
reservationIdentity: object;
wasEnabled: boolean;
payload?: CronPayload;
evaluateTrigger?: boolean;
streamBatch?: string;
streamScheduleKey?: string;
streamSourceIdentity?: string;
onTriggerDisposition?: (disposition: "fired" | "dropped" | "busy" | "error") => void;
}
| { ok: false };
type ActivatedManualRun = Extract<PreparedManualRun, { ran: true }> & {
startedAt: number;
taskRunId?: string;
activeJobMarker?: CronActiveJobMarker;
executionJob: CronJob;
};
type ManualRunOptions = {
runId?: string;
payload?: CronPayload;
terminalTracker?: ManualRunTerminalTracker;
owningCronLaneTaskMarker?: CommandLaneTaskMarker;
evaluateTrigger?: boolean;
streamBatch?: string;
streamScheduleKey?: string;
streamSourceIdentity?: string;
onTriggerDisposition?: (disposition: "fired" | "dropped" | "busy" | "error") => void;
};
type ManualRunTerminalTracker = { emitted: boolean };
function emitCronRunFinished(
state: CronServiceState,
evt: CronEvent & { action: "finished" },
tracker?: ManualRunTerminalTracker,
taskRunId?: string,
details?: {
triggerEval?: CronTriggerEvalOutcome;
scriptResult?: { scriptStateChanged?: boolean; scriptState?: unknown };
errorClassification?: CronRunErrorClassification;
},
): void {
tryFinishCronTaskRun(state, {
taskRunId,
job: evt.job,
event: evt,
errorClassification: details?.errorClassification,
...(details?.scriptResult ? { scriptResult: details.scriptResult } : {}),
...(details?.triggerEval ? { triggerEval: details.triggerEval } : {}),
});
emit(state, evt);
if (tracker) {
tracker.emitted = true;
}
}
type ManualRunDisposition =
| Extract<PreparedManualRun, { ran: false }>
| { ok: true; runnable: true };
type ManualRunPreflightResult =
| { ok: false }
| Extract<PreparedManualRun, { ran: false }>
| {
ok: true;
runnable: true;
job: CronJob;
now: number;
};
let nextManualRunId = 1;
function ownsStreamSource(
job: CronJob,
streamScheduleKey: string,
streamSourceIdentity: string,
): boolean {
return (
job.schedule.kind === "stream" &&
cronStreamScheduleKey(job.schedule) === streamScheduleKey &&
job.state.streamSourceIdentity === streamSourceIdentity
);
}
function admitsStreamSourceRun(
job: CronJob,
streamScheduleKey?: string,
streamSourceIdentity?: string,
): boolean {
if (streamScheduleKey === undefined && streamSourceIdentity === undefined) {
return true;
}
return (
streamScheduleKey !== undefined &&
streamSourceIdentity !== undefined &&
isJobEnabled(job) &&
ownsStreamSource(job, streamScheduleKey, streamSourceIdentity)
);
}
async function skipInvalidPersistedManualRun(params: {
state: CronServiceState;
job: CronJob;
mode?: "due" | "force";
runId?: string;
terminalTracker?: ManualRunTerminalTracker;
error: unknown;
}) {
const rollbackSnapshot = snapshotStoreForRollback(params.state);
const endedAt = params.state.deps.nowMs();
const errorText = normalizeCronRunErrorText(params.error);
const diagnostics = createCronRunDiagnosticsFromError("cron-preflight", errorText, {
severity: "warn",
nowMs: params.state.deps.nowMs,
});
applyJobResult(
params.state,
params.job,
{
status: "skipped",
error: errorText,
diagnostics,
startedAt: endedAt,
endedAt,
},
{ scheduleMode: params.mode === "force" ? "preserve" : "advance" },
);
emitCronRunFinished(
params.state,
{
jobId: params.job.id,
action: "finished",
job: params.job,
status: "skipped",
error: errorText,
diagnostics,
runId: params.runId,
runAtMs: endedAt,
durationMs: params.job.state.lastDurationMs,
nextRunAtMs: params.job.state.nextRunAtMs,
deliveryStatus: params.job.state.lastDeliveryStatus,
deliveryError: params.job.state.lastDeliveryError,
failureNotificationDelivery: failureNotificationDeliveryFromJobState(params.job),
},
params.terminalTracker,
);
recomputeNextRunsForMaintenance(params.state, {
recomputeExpired: true,
...(params.mode === "force"
? {
preserveExpiredPacedNextRunJobId: params.job.id,
}
: {}),
});
await persistOrRestore(params.state, rollbackSnapshot);
armTimer(params.state);
}
async function inspectManualRunPreflight(
state: CronServiceState,
id: string,
mode?: "due" | "force",
runId?: string,
terminalTracker?: ManualRunTerminalTracker,
streamScheduleKey?: string,
streamSourceIdentity?: string,
): Promise<ManualRunPreflightResult> {
return await locked(state, async () => {
warnIfDisabled(state, "run");
await ensureLoaded(state, { skipRecompute: true });
if (state.stopped) {
return { ok: true, ran: false, reason: "stopped" } as const;
}
if (state.restartRecoveryPending) {
return { ok: true, ran: false, reason: "restart-recovery-pending" } as const;
}
// Normalize job tick state (clears stale runningAtMs markers) before
// checking if already running, so a stale marker from a crashed Phase-1
// persist does not block manual triggers for up to STUCK_RUN_MS (#17554).
recomputeNextRunsForMaintenance(
state,
mode === "force" ? { preserveExpiredPacedNextRunJobId: id } : undefined,
);
const job = findJobOrThrow(state, id);
if (!admitsStreamSourceRun(job, streamScheduleKey, streamSourceIdentity)) {
return { ok: true, ran: false, reason: "not-due" } as const;
}
try {
assertSupportedJobSpec(job);
} catch (error) {
await skipInvalidPersistedManualRun({ state, job, mode, runId, terminalTracker, error });
return { ok: true, ran: false, reason: "invalid-spec" as const };
}
if (hasActiveCronRun(job)) {
return { ok: true, ran: false, reason: "already-running" as const };
}
const now = state.deps.nowMs();
const due = isJobDue(job, now, { forced: mode === "force" });
if (!due) {
return { ok: true, ran: false, reason: "not-due" } as const;
}
return { ok: true, runnable: true, job, now } as const;
});
}
async function inspectManualRunDisposition(
state: CronServiceState,
id: string,
mode?: "due" | "force",
): Promise<ManualRunDisposition | { ok: false }> {
// Queue callers need a cheap eligibility check before entering the command
// lane; the real reservation happens later under lock in prepareManualRun.
const result = await inspectManualRunPreflight(state, id, mode);
if (!result.ok) {
return result;
}
if ("reason" in result) {
return result;
}
return { ok: true, runnable: true } as const;
}
async function prepareManualRun(
state: CronServiceState,
id: string,
mode?: "due" | "force",
opts?: ManualRunOptions,
): Promise<PreparedManualRun> {
const preflight = await inspectManualRunPreflight(
state,
id,
mode,
opts?.runId,
opts?.terminalTracker,
opts?.streamScheduleKey,
opts?.streamSourceIdentity,
);
if (!preflight.ok) {
return preflight;
}
if ("reason" in preflight) {
return {
ok: true,
ran: false,
reason: preflight.reason,
} as const;
}
return await locked(state, async () => {
// Reserve this run under lock, then execute outside lock so read ops
// (`list`, `status`) stay responsive while the run is in progress.
if (state.stopped) {
return { ok: true, ran: false, reason: "stopped" as const };
}
if (state.restartRecoveryPending) {
return { ok: true, ran: false, reason: "restart-recovery-pending" as const };
}
// The initial preflight is advisory. A command-lane wait or another cron
// run can change this job before its reservation is persisted.
await ensureLoaded(state, { skipRecompute: true });
recomputeNextRunsForMaintenance(
state,
mode === "force" ? { preserveExpiredPacedNextRunJobId: id } : undefined,
);
const job = findJobOrThrow(state, id);
if (!admitsStreamSourceRun(job, opts?.streamScheduleKey, opts?.streamSourceIdentity)) {
return { ok: true, ran: false, reason: "not-due" as const };
}
try {
assertSupportedJobSpec(job);
} catch (error) {
await skipInvalidPersistedManualRun({
state,
job,
mode,
runId: opts?.runId,
terminalTracker: opts?.terminalTracker,
error,
});
return { ok: true, ran: false, reason: "invalid-spec" as const };
}
if (hasActiveCronRun(job)) {
return { ok: true, ran: false, reason: "already-running" as const };
}
const reservationAt = state.deps.nowMs();
if (!isJobDue(job, reservationAt, { forced: mode === "force" })) {
return { ok: true, ran: false, reason: "not-due" as const };
}
const reservationRollbackSnapshot = snapshotStoreForRollback(state);
job.state.queuedAtMs = reservationAt;
// Persist the queued marker before releasing lock so timer ticks that
// force-reload from disk cannot start the same job concurrently.
await persistOrRestore(state, reservationRollbackSnapshot);
const reservationIdentity = reserveQueuedCronRun(state, job.id, reservationAt, {
preserveWhenDisabled: mode === "force" && !isJobEnabled(job),
});
if (state.stopped) {
const cleanup = async () => {
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
const persistedJob = state.store?.jobs.find((entry) => entry.id === id);
if (
typeof persistedJob?.state.queuedAtMs !== "number" ||
!isQueuedCronRunReservationMarkerCurrent(
state,
job.id,
reservationIdentity,
persistedJob.state.queuedAtMs,
)
) {
releaseQueuedCronRun(state, job.id, reservationIdentity);
return;
}
const rollbackSnapshot = snapshotStoreForRollback(state);
delete persistedJob.state.queuedAtMs;
await persistOrRestore(state, rollbackSnapshot);
releaseQueuedCronRun(state, job.id, reservationIdentity);
};
try {
await cleanup();
} catch {
try {
await cleanup();
} catch (error) {
// The stopped service has no cleanup owner left. Drop the process
// claim so restart/stuck-marker recovery can repair the durable marker.
releaseQueuedCronRun(state, job.id, reservationIdentity);
throw error;
}
}
return { ok: true, ran: false, reason: "stopped" as const };
}
return {
ok: true,
ran: true,
jobId: job.id,
runId: opts?.runId,
terminalTracker: opts?.terminalTracker,
owningCronLaneTaskMarker: opts?.owningCronLaneTaskMarker,
reservationAt,
reservationIdentity,
wasEnabled: isJobEnabled(job),
...(opts?.payload ? { payload: structuredClone(opts.payload) } : {}),
...(opts?.evaluateTrigger ? { evaluateTrigger: true } : {}),
...(opts?.streamBatch !== undefined ? { streamBatch: opts.streamBatch } : {}),
...(opts?.streamScheduleKey !== undefined
? { streamScheduleKey: opts.streamScheduleKey }
: {}),
...(opts?.streamSourceIdentity !== undefined
? { streamSourceIdentity: opts.streamSourceIdentity }
: {}),
...(opts?.onTriggerDisposition ? { onTriggerDisposition: opts.onTriggerDisposition } : {}),
} as const;
});
}
async function activatePreparedManualRun(
state: CronServiceState,
prepared: Extract<PreparedManualRun, { ran: true }>,
mode?: "due" | "force",
): Promise<ActivatedManualRun | Extract<PreparedManualRun, { ran: false }>> {
return await locked(state, async () => {
// Reservations can wait behind another cron run. Reload under the service
// lock so disabling, rescheduling, or removing the job wins that wait.
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
if (state.stopped) {
await releasePreparedManualReservationWithRetry(state, prepared);
return { ok: true, ran: false, reason: "stopped" } as const;
}
if (state.restartRecoveryPending) {
await releasePreparedManualReservationWithRetry(state, prepared);
return { ok: true, ran: false, reason: "restart-recovery-pending" } as const;
}
const job = state.store?.jobs.find((entry) => entry.id === prepared.jobId);
if (!job) {
await releasePreparedManualReservationWithRetry(state, prepared);
return { ok: true, ran: false, reason: "not-due" } as const;
}
if (
!isQueuedCronRunReservationCurrent(state, prepared.jobId, prepared.reservationIdentity) ||
job.state.queuedAtMs !== prepared.reservationAt
) {
await releasePreparedManualReservationWithRetry(state, prepared);
return { ok: true, ran: false, reason: "not-due" } as const;
}
if (!admitsStreamSourceRun(job, prepared.streamScheduleKey, prepared.streamSourceIdentity)) {
// This is reservation identity, not watcher ownership: a force run can
// wait behind cron admission after its owner has stopped for replacement.
// The logical source identity rejects retired batches even when the
// schedule key is unchanged (disable→re-enable, A→B→A).
await releasePreparedManualReservationWithRetry(state, prepared);
return { ok: true, ran: false, reason: "not-due" } as const;
}
const dueProbe = structuredClone(job);
delete dueProbe.state.queuedAtMs;
if (
(prepared.wasEnabled && !isJobEnabled(job)) ||
!isJobDue(dueProbe, state.deps.nowMs(), { forced: mode === "force" })
) {
await releasePreparedManualReservationWithRetry(state, prepared);
return { ok: true, ran: false, reason: "not-due" } as const;
}
try {
assertSupportedJobSpec(job);
} catch (error) {
await skipInvalidPersistedManualRun({
state,
job,
mode,
runId: prepared.runId,
terminalTracker: prepared.terminalTracker,
error,
});
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
return { ok: true, ran: false, reason: "invalid-spec" } as const;
}
const startedAt = state.deps.nowMs();
const previousLastError = job.state.lastError;
const activationRollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.queuedAtMs;
job.state.runningAtMs = startedAt;
job.state.lastError = undefined;
// A failed write restores the durable reservation; run() owns releasing
// that queued claim for every activation failure before it propagates.
await persistOrRestore(state, activationRollbackSnapshot);
updateQueuedCronRunReservationMarker(
state,
prepared.jobId,
prepared.reservationIdentity,
startedAt,
previousLastError,
);
if (state.stopped || state.restartRecoveryPending) {
job.state.lastError = previousLastError;
const rollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.runningAtMs;
try {
await persistOrRestore(state, rollbackSnapshot);
} catch (error) {
await releasePreparedManualReservationWithRetry(state, prepared);
throw error;
}
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
return {
ok: true,
ran: false,
reason: state.stopped ? "stopped" : "restart-recovery-pending",
} as const;
}
emit(state, { jobId: job.id, action: "started", job, runAtMs: startedAt });
const taskRunId = tryCreateCronTaskRun({
state,
job,
startedAt,
publicRunId: prepared.runId,
});
const activeJobMarker = markManualCronJobActive(state, job);
// Execute against a snapshot so later reload/merge can preserve delivery
// target writeback from disk without mutating the running object.
const executionJob = structuredClone(job);
if (mode === "force" && executionJob.trigger && !prepared.evaluateTrigger) {
// Force means run the payload now; strip the gate only from this snapshot
// so persisted trigger state and future due evaluations stay intact.
delete executionJob.trigger;
}
if (prepared.payload) {
executionJob.payload = structuredClone(prepared.payload);
}
return {
...prepared,
startedAt,
runId: prepared.runId ?? taskRunId,
taskRunId,
activeJobMarker,
executionJob,
} as const;
});
}
async function releasePreparedManualReservation(
state: CronServiceState,
prepared: Extract<PreparedManualRun, { ran: true }>,
): Promise<void> {
if (!isQueuedCronRunReservationCurrent(state, prepared.jobId, prepared.reservationIdentity)) {
return;
}
const job = state.store?.jobs.find((entry) => entry.id === prepared.jobId);
const rollbackSnapshot = snapshotStoreForRollback(state);
if (
!job ||
!clearQueuedCronRunReservationMarker(
state,
prepared.jobId,
prepared.reservationIdentity,
job.state,
)
) {
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
return;
}
await persistOrRestore(state, rollbackSnapshot);
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
}
async function releasePreparedManualReservationWithRetry(
state: CronServiceState,
prepared: Extract<PreparedManualRun, { ran: true }>,
): Promise<void> {
try {
await releasePreparedManualReservation(state, prepared);
} catch {
try {
await releasePreparedManualReservation(state, prepared);
} catch (error) {
// No caller owns another retry. Let stale-marker recovery see the
// durable marker instead of retaining a process-only queued claim.
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
throw error;
}
}
}
async function releasePreparedManualReservationAfterReloadWithRetry(
state: CronServiceState,
prepared: Extract<PreparedManualRun, { ran: true }>,
): Promise<void> {
const attempt = async () => {
await locked(state, async () => {
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
await releasePreparedManualReservation(state, prepared);
});
};
try {
await attempt();
} catch {
try {
await attempt();
} catch (error) {
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
throw error;
}
}
}
async function finishPreparedManualRun(
state: CronServiceState,
prepared: ActivatedManualRun,
mode?: "due" | "force",
): Promise<void> {
const executionJob = prepared.executionJob;
const startedAt = prepared.startedAt;
const jobId = prepared.jobId;
const taskRunId = prepared.taskRunId;
const runId = prepared.runId;
try {
let coreResult: Awaited<ReturnType<typeof executeJobCoreWithTimeout>>;
try {
coreResult = await executeJobCoreWithTimeout(state, executionJob, {
runId: taskRunId,
activeJobMarker: prepared.activeJobMarker,
owningCronLaneTaskMarker: prepared.owningCronLaneTaskMarker,
streamBatch: prepared.streamBatch,
streamScheduleKey: prepared.streamScheduleKey,
streamSourceIdentity: prepared.streamSourceIdentity,
});
} catch (err) {
coreResult = { status: "error", error: normalizeCronRunErrorText(err) };
}
if (prepared.onTriggerDisposition) {
const disposition = coreResult.triggerEval?.busy
? "busy"
: coreResult.status === "error"
? "error"
: coreResult.status !== "ok"
? "dropped"
: !executionJob.trigger
? "fired"
: coreResult.triggerEval?.fired
? "fired"
: "dropped";
prepared.onTriggerDisposition(disposition);
}
const endedAt = state.deps.nowMs();
const triggerSkipped = coreResult.status === "ok" && coreResult.triggerEval?.fired === false;
const emitMissingQueuedTerminal = () => {
const tracker = prepared.terminalTracker;
if (!tracker || tracker.emitted) {
return;
}
const job = state.store?.jobs.find((entry) => entry.id === jobId);
// enqueueRun acknowledges a concrete run id, so every accepted request
// needs one terminal event even if the job or service owner changes mid-run.
emitCronRunFinished(
state,
{
jobId,
action: "finished",
job,
status: triggerSkipped ? "skipped" : coreResult.status,
error: triggerSkipped
? "queued manual run skipped: trigger condition not met"
: coreResult.error,
deliveryError: coreResult.deliveryError,
summary: triggerSkipped ? undefined : coreResult.summary,
diagnostics: coreResult.diagnostics,
delivered: coreResult.delivered,
delivery: coreResult.delivery,
sessionId: coreResult.sessionId,
sessionKey: coreResult.sessionKey,
runId,
runAtMs: startedAt,
durationMs: Math.max(0, endedAt - startedAt),
nextRunAtMs: job?.state.nextRunAtMs,
model: coreResult.model,
provider: coreResult.provider,
usage: coreResult.usage,
},
tracker,
taskRunId,
{
errorClassification: triggerSkipped ? undefined : coreResult.errorClassification,
},
);
};
if (!triggerSkipped) {
// Terminal state must land even if the store merge below throws; the later
// emitCronRunFinished re-finalizes the same row to attach history detail
// (same-status terminal updates apply, so this does not race precedence).
tryFinishCronTaskRunWithoutHistory(state, {
taskRunId,
status: coreResult.status,
error: coreResult.error,
endedAt,
summary: coreResult.summary,
childSessionKey: coreResult.sessionKey,
});
}
if (!isCronActiveJobMarkerCurrent(prepared.activeJobMarker)) {
emitMissingQueuedTerminal();
return;
}
let finalized = false;
let notifySetupTimeout = coreResult.isolatedAgentSetupTimeout !== undefined;
await locked(state, async () => {
await ensureLoaded(state, { skipRecompute: true });
if (!isCronActiveJobMarkerCurrent(prepared.activeJobMarker)) {
notifySetupTimeout = false;
return;
}
const job = state.store?.jobs.find((entry) => entry.id === jobId);
if (!job) {
return;
}
let shouldDelete = false;
if (coreResult.status === "ok" && coreResult.triggerEval?.fired === false) {
// Manual due checks share scheduled quiet-tick semantics: persist the
// evaluation but create no finished event or run-history entry.
applyTriggerNoFireResult(
state,
job,
{
startedAt,
endedAt,
triggerEval: coreResult.triggerEval,
},
{ scheduleMode: mode === "force" ? "preserve" : "advance" },
);
} else {
shouldDelete = applyJobResult(
state,
job,
{
...coreResult,
startedAt,
endedAt,
},
{ scheduleMode: mode === "force" ? "preserve" : "advance" },
);
applyTriggerRunResult(job, {
status: coreResult.status,
endedAt,
triggerEval: coreResult.triggerEval,
});
applyScriptRunResult(job, coreResult);
// Stream payloads are event-owned by their batch. Generic recurring
// error backoff must not synthesize a later run without that batch.
if (job.schedule.kind === "stream") {
job.state.nextRunAtMs = undefined;
}
emitCronRunFinished(
state,
{
jobId: job.id,
action: "finished",
job,
status: coreResult.status,
error: coreResult.error,
summary: coreResult.summary,
diagnostics: coreResult.diagnostics,
delivered: job.state.lastDelivered,
deliveryStatus: job.state.lastDeliveryStatus,
deliveryError: job.state.lastDeliveryError,
failureNotificationDelivery: failureNotificationDeliveryFromJobState(job),
delivery: coreResult.delivery,
sessionId: coreResult.sessionId,
sessionKey: coreResult.sessionKey,
runId,
runAtMs: startedAt,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
...(coreResult.triggerEval?.fired ? { triggerFired: true } : {}),
model: coreResult.model,
provider: coreResult.provider,
usage: coreResult.usage,
},
prepared.terminalTracker,
taskRunId,
{
triggerEval: coreResult.triggerEval,
scriptResult: coreResult,
errorClassification: coreResult.errorClassification,
},
);
}
// Manual runs should not advance other due jobs without executing them.
// Use maintenance-only recompute to repair missing values while
// preserving existing past-due nextRunAtMs entries for future timer ticks.
const postRunSnapshot = shouldDelete
? null
: {
enabled: job.enabled,
updatedAtMs: job.updatedAtMs,
state: structuredClone(job.state),
};
const postRunRemoved = shouldDelete;
const removedJob = shouldDelete ? structuredClone(job) : undefined;
// Isolated Telegram send can persist target writeback directly to disk.
// Reload before final persist so manual `cron run` keeps those changes.
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
if (!isCronActiveJobMarkerCurrent(prepared.activeJobMarker)) {
notifySetupTimeout = false;
return;
}
const rollbackSnapshot = snapshotStoreForRollback(state);
mergeManualRunSnapshotAfterReload({
state,
jobId,
snapshot: postRunSnapshot,
removed: postRunRemoved,
});
recomputeNextRunsForMaintenance(state, {
recomputeExpired: true,
...(mode === "force"
? {
preserveExpiredPacedNextRunJobId: jobId,
}
: {}),
});
await persistOrRestore(state, rollbackSnapshot);
if (removedJob) {
emit(state, { jobId: removedJob.id, action: "removed", job: removedJob });
}
finalized = true;
});
if (notifySetupTimeout && isCronActiveJobMarkerCurrent(prepared.activeJobMarker)) {
maybeNotifyManualIsolatedSetupTimeout(state, {
jobId,
job: executionJob,
isolatedAgentSetupTimeout: coreResult.isolatedAgentSetupTimeout,
});
}
if (finalized) {
if (triggerSkipped) {
tryFinishCronTaskRunWithoutHistory(state, {
taskRunId,
status: coreResult.status,
error: coreResult.error,
endedAt,
summary: coreResult.summary,
childSessionKey: coreResult.sessionKey,
});
}
armTimer(state);
}
emitMissingQueuedTerminal();
} finally {
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
clearManualCronJobActive(state, jobId, prepared.activeJobMarker);
}
}
/** Runs a cron job manually, reserving it under lock before executing outside the lock. */
export async function run(
state: CronServiceState,
id: string,
mode?: "due" | "force",
opts?: ManualRunOptions,
) {
const prepared = await prepareManualRun(state, id, mode, opts);
if (!prepared.ok || !prepared.ran) {
return prepared;
}
const admission = await runWithCronAdmission(state, async () => {
let activeRun: Awaited<ReturnType<typeof activatePreparedManualRun>>;
try {
activeRun = await activatePreparedManualRun(state, prepared, mode);
} catch (error) {
// Activation failures still own the original durable reservation. Once
// activation succeeds, finishPreparedManualRun releases it after execution.
try {
await locked(state, async () => {
await releasePreparedManualReservationWithRetry(state, prepared);
});
} catch (cleanupError) {
state.deps.log.warn(
{ jobId: prepared.jobId, err: String(cleanupError) },
"cron: failed to release manual run reservation after activation error",
);
}
throw error;
}
if (!activeRun.ran) {
return activeRun;
}
await finishPreparedManualRun(state, activeRun, mode);
return { ok: true, ran: true } as const;
});
if (admission.kind === "stopped") {
await releasePreparedManualReservationAfterReloadWithRetry(state, prepared);
return { ok: true, ran: false, reason: "stopped" } as const;
}
return admission.value;
}
/** Queues a manual cron run behind the cron command lane and returns an immediate run id. */
export async function enqueueRun(state: CronServiceState, id: string, mode?: "due" | "force") {
const disposition = await inspectManualRunDisposition(state, id, mode);
if (!disposition.ok || !("runnable" in disposition && disposition.runnable)) {
return disposition;
}
const runId = `manual:${id}:${state.deps.nowMs()}:${nextManualRunId++}`;
const terminalTracker: ManualRunTerminalTracker = { emitted: false };
void runWithGatewayIndependentRootWorkContinuation(() =>
enqueueCommandInLane(
CommandLane.Cron,
async (owningCronLaneTaskMarker) => {
const result = await run(state, id, mode, {
runId,
terminalTracker,
owningCronLaneTaskMarker,
});
if (result.ok && "ran" in result && !result.ran) {
if (result.reason !== "invalid-spec") {
const finishedAt = state.deps.nowMs();
const job = state.store?.jobs.find((entry) => entry.id === id);
emitCronRunFinished(
state,
{
jobId: id,
action: "finished",
job,
status: "skipped",
error: `queued manual run skipped before execution: ${result.reason}`,
runId,
runAtMs: finishedAt,
durationMs: 0,
nextRunAtMs: job?.state.nextRunAtMs,
},
terminalTracker,
);
}
state.deps.log.info(
{ jobId: id, runId, reason: result.reason },
"cron: queued manual run skipped before execution",
);
}
return result;
},
{
warnAfterMs: 5_000,
onWait: (waitMs, queuedAhead) => {
state.deps.log.warn(
{ jobId: id, runId, waitMs, queuedAhead },
"cron: queued manual run waiting for an execution slot",
);
},
},
),
).catch((err: unknown) => {
if (terminalTracker.emitted) {
state.deps.log.error(
{ jobId: id, runId, err: String(err) },
"cron: queued manual run failed after emitting its terminal event",
);
return;
}
const finishedAt = state.deps.nowMs();
const job = state.store?.jobs.find((entry) => entry.id === id);
emitCronRunFinished(
state,
{
jobId: id,
action: "finished",
job,
status: "error",
error: normalizeCronRunErrorText(err),
runId,
runAtMs: finishedAt,
durationMs: 0,
nextRunAtMs: job?.state.nextRunAtMs,
},
terminalTracker,
);
state.deps.log.error(
{ jobId: id, runId, err: String(err) },
"cron: queued manual run background execution failed",
);
});
return { ok: true, enqueued: true, runId } as const;
}
/** Enqueues manual wake text through the cron wake API. */
export function wakeNow(
state: CronServiceState,
opts: { mode: CronWakeMode; text: string; sessionKey?: string; agentId?: string },
) {
return wake(state, opts);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */