Add cron monitor APIs: labels, watch, progress, and query tools.

Extend jobs with lifecycle projection and history policies so listeners can poll state at scale without breaking existing cron_* flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 15:23:42 +08:00
parent 43facd608a
commit b6934b20a3
10 changed files with 1501 additions and 53 deletions

View file

@ -7,6 +7,14 @@ import { randomUUID } from 'node:crypto'
import { wrapScheduledPrompt, scheduledPromptSource } from './prompt.js'
import { decideDispatch } from './scheduler.js'
import { appendRun, getJob, patchRun, upsertJob } from './store.js'
import { ACTIVE_RUN_STATUSES } from './fire-status.js'
import {
findLabelOverlapRun,
normalizeLabels,
projectJobState,
} from './monitor.js'
export { ACTIVE_RUN_STATUSES } from './fire-status.js'
export function createRunRecord(job, decision, now, trigger) {
return {
@ -15,11 +23,14 @@ export function createRunRecord(job, decision, now, trigger) {
scheduledAt: decision.scheduledAt ?? now,
actualAt: now,
status: 'queued',
stateEnteredAt: now,
exitedAt: null,
trigger: trigger || 'schedule',
sessionId: null,
error: null,
summary: '',
reason: decision.reason || null,
outputRef: null,
}
}
@ -43,8 +54,48 @@ export function extractAssistantText(messages, maxChars = 4000) {
return ''
}
export function publicJob(job) {
export function publicJob(job, runs = null) {
if (!job) return null
const jobRuns = Array.isArray(runs)
? runs.filter((run) => run?.jobId === job.id)
: null
const projection = jobRuns
? projectJobState(job, jobRuns)
: projectJobState(job, job.lastStatus
? [{ status: job.lastStatus, stateEnteredAt: job.lastRunAt, actualAt: job.lastRunAt, exitedAt: job.lastRunAt }]
: [])
// When runs not provided, prefer lastStatus-based active hint only for running/queued tip.
let stateInfo = projection
if (!jobRuns && (job.lastStatus === 'running' || job.lastStatus === 'queued')) {
stateInfo = {
state: job.lastStatus === 'queued' ? 'pending' : 'running',
stateEnteredAt: job.lastRunAt || job.updatedAt || null,
activeRunId: null,
stuck: false,
}
} else if (!jobRuns && job.enabled === false) {
stateInfo = {
state: 'paused',
stateEnteredAt: job.updatedAt || job.createdAt || null,
activeRunId: null,
stuck: false,
}
} else if (!jobRuns && job.schedule?.kind === 'at' && job.nextRunAt == null
&& (job.lastStatus === 'succeeded' || job.lastStatus === 'failed' || job.lastStatus === 'skipped')) {
stateInfo = {
state: job.lastStatus === 'succeeded' ? 'succeeded' : 'failed',
stateEnteredAt: job.lastRunAt || null,
activeRunId: null,
stuck: false,
}
} else if (!jobRuns) {
stateInfo = {
state: 'idle',
stateEnteredAt: job.lastRunAt || job.updatedAt || job.createdAt || null,
activeRunId: null,
stuck: false,
}
}
return {
id: job.id,
name: job.name,
@ -68,6 +119,14 @@ export function publicJob(job) {
}
: { kind: 'dsh' },
mirrorToSession: job.mirrorToSession === true,
labels: normalizeLabels(job.labels),
...job.watch ? { watch: job.watch } : {},
...job.progress ? { progress: job.progress } : {},
...job.report ? { report: job.report } : {},
persistHistory: job.persistHistory || { kind: 'retain', limit: 200 },
state: stateInfo.state,
stateEnteredAt: stateInfo.stateEnteredAt,
stuck: stateInfo.stuck === true,
...job.origin ? { origin: job.origin } : {},
schedule: job.schedule,
createdAt: job.createdAt,
@ -80,6 +139,7 @@ export function publicJob(job) {
/**
* Claim one occurrence: overlap → skipped history row; otherwise queued run.
* Also skips when another job with the same role+task labels is already active.
*/
export function claimOccurrence(state, jobId, now, trigger, policies) {
const job = getJob(state, jobId)
@ -89,7 +149,7 @@ export function claimOccurrence(state, jobId, now, trigger, policies) {
throw error
}
const runs = (state.runs || []).filter((run) => run.jobId === jobId)
const decision = trigger === 'run-now'
let decision = trigger === 'run-now'
? decideRunNow(job, runs, now, policies)
: decideDispatch({
job,
@ -100,17 +160,33 @@ export function claimOccurrence(state, jobId, now, trigger, policies) {
graceMs: policies?.graceMs,
})
if (decision.action === 'fire') {
const overlap = findLabelOverlapRun(state, job)
if (overlap) {
decision = {
action: 'skip',
reason: 'label_overlap',
scheduledAt: decision.scheduledAt ?? now,
nextRunAt: Object.hasOwn(decision, 'nextRunAt') ? decision.nextRunAt : job.nextRunAt,
}
}
}
if (decision.action === 'wait') {
return { state, job: publicJob(job), decision, run: null }
return { state, job: publicJob(job, state.runs), decision, run: null }
}
const run = createRunRecord(job, decision, now, trigger)
if (decision.action === 'skip') {
run.status = 'skipped'
run.stateEnteredAt = now
run.exitedAt = now
run.reason = decision.reason
const oneshot = job.schedule?.kind === 'at'
if (decision.reason === 'overlap') {
run.summary = 'Skipped because a run is already queued or running'
} else if (decision.reason === 'label_overlap') {
run.summary = 'Skipped because another job with the same role+task labels is already running'
} else if (decision.reason === 'misfire' && oneshot) {
run.summary = 'Skipped: one-shot time missed (host downtime or delayed tick; no backlog). nextRunAt cleared.'
run.error = 'misfire:one-shot'
@ -135,10 +211,11 @@ export function claimOccurrence(state, jobId, now, trigger, policies) {
}
let next = upsertJob(state, nextJob)
if (!alreadySkipped) next = appendRun(next, run)
return { state: next, job: publicJob(nextJob), decision, run: alreadySkipped ? null : run }
return { state: next, job: publicJob(nextJob, next.runs), decision, run: alreadySkipped ? null : run }
}
run.status = 'queued'
run.stateEnteredAt = now
const nextJob = {
...job,
updatedAt: now,
@ -148,7 +225,7 @@ export function claimOccurrence(state, jobId, now, trigger, policies) {
}
let next = upsertJob(state, nextJob)
next = appendRun(next, run)
return { state: next, job: publicJob(nextJob), decision, run }
return { state: next, job: publicJob(nextJob, next.runs), decision, run }
}
function decideRunNow(job, runs, now, policies) {
@ -159,8 +236,6 @@ function decideRunNow(job, runs, now, policies) {
return { action: 'fire', scheduledAt: now, nextRunAt: job.nextRunAt }
}
export const ACTIVE_RUN_STATUSES = new Set(['queued', 'running'])
function failRun(state, runId, job, now, message) {
return settleRun(state, runId, {
status: 'failed',
@ -177,12 +252,16 @@ export function settleRun(state, runId, terminal, now) {
const status = terminal?.status && !ACTIVE_RUN_STATUSES.has(terminal.status)
? terminal.status
: 'succeeded'
const prevStatus = run.status
let next = patchRun(state, runId, {
status,
summary: terminal?.summary || '',
error: terminal?.error || null,
actualAt: now,
stateEnteredAt: prevStatus === status ? (run.stateEnteredAt || now) : now,
exitedAt: now,
sessionId: run.sessionId,
...terminal?.outputRef ? { outputRef: terminal.outputRef } : {},
})
if (job) {
next = upsertJob(next, {
@ -222,7 +301,12 @@ export async function executeClaimedRun(state, runId, deps) {
}
const clock = deps.now || (() => Date.now())
const mark = clock()
let next = patchRun(state, runId, { status: 'running', actualAt: mark })
let next = patchRun(state, runId, {
status: 'running',
actualAt: mark,
stateEnteredAt: mark,
exitedAt: null,
})
next = upsertJob(next, { ...job, lastRunAt: mark, lastStatus: 'running', updatedAt: mark })
const text = wrapScheduledPrompt(job.prompt, {
@ -235,7 +319,11 @@ export async function executeClaimedRun(state, runId, deps) {
if (typeof deps.createAndPrompt !== 'function') {
next = failRun(next, runId, job, clock(), 'session port unavailable')
return { state: next, run: (next.runs || []).find((row) => row.id === runId), job: publicJob(getJob(next, job.id)) }
return {
state: next,
run: (next.runs || []).find((row) => row.id === runId),
job: publicJob(getJob(next, job.id), next.runs),
}
}
try {
@ -256,12 +344,20 @@ export async function executeClaimedRun(state, runId, deps) {
const waited = await deps.waitForTurn(sessionId)
const ended = clock()
next = settleRun(next, runId, waited || { status: 'succeeded', summary: created.summary }, ended)
return { state: next, run: (next.runs || []).find((row) => row.id === runId), job: publicJob(getJob(next, job.id)) }
return {
state: next,
run: (next.runs || []).find((row) => row.id === runId),
job: publicJob(getJob(next, job.id), next.runs),
}
}
if (ACTIVE_RUN_STATUSES.has(reported)) {
// Leave running so overlap skip still works while a turn is in flight.
// Caller (dispatchRun / live waitForTurn) must settle to a terminal status.
return { state: next, run: (next.runs || []).find((row) => row.id === runId), job: publicJob(getJob(next, job.id)) }
return {
state: next,
run: (next.runs || []).find((row) => row.id === runId),
job: publicJob(getJob(next, job.id), next.runs),
}
}
const ended = clock()
next = settleRun(next, runId, {
@ -269,11 +365,19 @@ export async function executeClaimedRun(state, runId, deps) {
summary: created.summary || 'Dispatched to a new session',
error: created.error || null,
}, ended)
return { state: next, run: (next.runs || []).find((row) => row.id === runId), job: publicJob(getJob(next, job.id)) }
return {
state: next,
run: (next.runs || []).find((row) => row.id === runId),
job: publicJob(getJob(next, job.id), next.runs),
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
next = failRun(next, runId, job, clock(), message)
return { state: next, run: (next.runs || []).find((row) => row.id === runId), job: publicJob(getJob(next, job.id)) }
return {
state: next,
run: (next.runs || []).find((row) => row.id === runId),
job: publicJob(getJob(next, job.id), next.runs),
}
}
}
@ -286,6 +390,8 @@ export function interruptActiveRuns(state, now, reason = 'host_interrupted') {
error: reason,
summary: 'Host stopped before this run finished',
actualAt: now,
stateEnteredAt: now,
exitedAt: now,
})
const job = getJob(next, run.jobId)
if (job) {