docs/references/job-and-scheduler/overview.md
Two independent main-process lifecycle services:
| Service | Role | Persistence | Direct consumer |
|---|---|---|---|
| SchedulerService | "When to fire a callback" — cron / interval / once. Stateless. | None | JobManager + any module needing simple time scheduling |
| JobManager | "Job lifecycle" — registry, persistence, 6-state machine, dispatch, recovery | jobTable + jobScheduleTable | All background work |
Layering rule: SchedulerService is unaware of Jobs. JobManager uses SchedulerService to arm schedules. Business modules pick one based on need:
jobManager.registerJobSchedule()schedulerService.registerSchedule() directlyBaseService.registerInterval (project convention, not SchedulerService)jobTable is the single source of truth. Memory state (handlers Map, queues Map, AbortControllers) is a derived view that JobManager rebuilds on every startup.
Each queue has a DispatchQueue instance holding { name, concurrency, mutex }. The dispatch loop (JobManager.dispatch):
BEGIN IMMEDIATE write transaction (withWriteTx) — secondqueue.concurrencyglobalMaxConcurrencyhandler.execute outside the lockSpawning happens outside the lock — the handler executes for seconds/minutes while new dispatches proceed.
Acquisition order is fixed (Layer 1 mutex, then the Layer 0 write transaction). Layer 0 holds no async lock — it is a synchronous transaction — so Layer 1 is the only mutex in the dispatch path and the two layers cannot deadlock against each other.
┌── retry backoff (delayed) ──┐
▼ │
enqueue → pending → running → completed │
│ │ │
│ └→ failed ─────────────┘ (if retryable && attempt < max)
│ └→ cancelled (terminal)
└→ delayed → (scheduledAt ≤ now) → pending
Terminal states (completed / failed / cancelled) are never reopened. Retry re-enters delayed then transitions back to pending when scheduledAt elapses.
Startup recovery is JobManager's deferred sweep that reconciles the DB-driven state machine with the freshly booted process. It is service-level business work, not a bootstrap initialization side effect — see onAllReady business work pattern for the framework-level rationale.
Sequence
JobManager.onAllReady() schedules a setTimeout with a 60-second "quiet window" and returns synchronously. LifecycleManager.allReady() is fire-and-forget; bootstrap is not blocked.this._recoveryDone (only if shutdown has not been requested) and the flow starts running.runStartupRecovery(handlers, isJobInFlight) — resets non-terminal rows per handler recovery strategy (abandon / retry / singleton); cancelRequested=true overrides every strategy. Rows the current process is already executing (reported via isJobInFlight, backed by JobManager.inFlightExecuted) are excluded before any strategy, so a job enqueued during the quiet window and still running when the sweep fires is never reset or re-dispatched (#16291).(queue, type) pairs over non-terminal rows and ensures a DispatchQueue exists for each. Without this step dispatchAll would iterate an empty queues map and pending rows would wait until the next enqueue.detectAndDispatchOverdue(schedules) before armSchedule(schedule) for every enabled schedule. The order is load-bearing: if we armed first, a cron with protect: true could fire its natural calendar concurrently with a catch-up enqueue (protect only blocks overlapping callbacks, not external callers). Sequencing catch-up first guarantees the make-up enqueue lands before croner's first natural fire.dispatchAll() kicks every per-queue pump so pending rows reset by step 1 start running immediately rather than waiting on the next enqueue.The 60 s quiet window
The delay (JOB_MANAGER_STARTUP_DELAY_MS = 60_000, hardcoded) gives cold-start IO — DB warm-up, window paints, client bootstrap — time to settle before scheduled work piles on. Tests bypass it via vi.useFakeTimers + advanceTimersByTimeAsync(60_000), then await _recoveryDone.
Shutdown safety — three layers
The flow can be interrupted at any point by onStop. Three mechanisms cooperate:
| Window | Defence |
|---|---|
| Quiet window (timer not yet fired) | registerDisposable(() => clearTimeout(handle)) clears the timer during _cleanupDisposables; the callback also re-checks _isShuttingDown so a teardown that races with clearTimeout is still safe. |
| Flow mid-flight | Every IO step re-checks _isShuttingDown before the next await, returning early on shutdown. |
| Flow already started | onStop awaits this._recoveryDone before tearing down resources, so the current step finishes gracefully before queues, abort controllers, and disposables are released. |
Handler registration timing
Handlers must be registered in the owning service's onInit (see handler-authoring.md — Registration Timing). By the time the 60-second timer fires every consumer has finished onInit / onReady, so runStartupRecovery sees the full handler set. Registering a handler from another service's onAllReady is unsafe: that hook runs in parallel with JobManager's, and any non-terminal job for an unregistered type during recovery gets treated as an orphan and cancelled.
Serves backup restore (#16850): after the restore snapshot is taken at time T, any JobManager write to the old live DB fails the fingerprint re-check and wastes the whole restore attempt. pause() stops autonomous writes to avoid that waste; the fingerprint gate stays the correctness backstop. The restore orchestrator must NOT run as a JobManager job — a handler that pauses and drains its own manager deadlocks until timeout.
const hold = jobManager.pause('backup restore')
const verdict = await jobManager.drainInFlight({ timeoutMs: 15_000 })
const clean = verdict.stragglerIds.length === 0 && !verdict.startupRecoveryPending
if (!clean) {
hold.dispose() // abort path ONLY — give the manager back its autonomy
return abortRestoreAttempt()
}
await createSnapshot()
// Happy path: NEVER dispose. The release pass writes to the old live DB
// (promotion, markFired, catch-up enqueues) — post-snapshot that fails the
// fingerprint re-check and voids the attempt. The hold stands until the
// process relaunches into the restored DB (a lost hold fails closed).
| Rule | Detail |
|---|---|
No resume() | Release = dispose your own hold. Holds are refcounted; the last dispose runs the compensation pass: any outstanding recovery settles FIRST — an internal release barrier keeps autonomous fires/claims frozen until it does (interval chains and croner timers would otherwise resume the moment the holds are gone and race the flow's stale-snapshot catch-up) — then delayed promotion + dispatch, suppressed-once re-arm, croner resume. A lost hold fails closed — paused until relaunch. |
| Drain precondition | Caller must hold a live pause hold. Without one the verdict is a point-in-time snapshot (warn, no throw) and MUST NOT gate a DB snapshot. |
| Clean verdict | stragglerIds empty and startupRecoveryPending === false. The deferred startup recovery is a JM-internal writer that is not a job, so it gets its own verdict field — never fake ids in stragglerIds. true means the flow is still blocked inside a step; a flow that short-circuited at a step boundary writes nothing more and reports false (the remainder is release's debt). |
| Timeout | drainInFlight never rejects. Stragglers are not aborted — an abort settles them as cancelled into the snapshot and they would never re-run after a restore; left running, startup recovery applies the handler strategy. Orchestrator rule: any drain timeout → abort the restore attempt. |
| No error surface | No API throws because of a pause; there is no pause-related error code. |
Blocked while paused (autonomous writes): dispatch claims (entry check + post-mutex re-check), schedule fire callbacks (crons are additionally paused at the croner layer so limit quotas survive the window), GC / delayed-promotion ticks, delayed/retry promotion fires, and new startup-recovery steps — a started step (one schedule's onMissed + catch-up enqueue, atomic) runs to completion and is awaited by drain.
Allowed while paused (request-driven): enqueue / enqueueTx (rows land at rest and the snapshot captures them), cancel / cancelMany, schedule mutations, and triggerJobScheduleNow* — forced onto its direct-enqueue fallback (row lands pending + markFired; true still means "row persisted").
Missed cron fires are skipped, not caught up (croner semantics). A suppressed once fire is re-armed on release from the recorded id set — exactly once; never rebuild by scanning "enabled ∧ missing scheduler entry", which also matches historical completed one-shots.
We considered BullMQ / bee-queue / better-queue / agenda / graphile-worker / bree etc. and selected this design because:
count → claimThroughput: ~200 dispatch/s at single-process better-sqlite3 throughput, well above Cherry Studio's largest scenario (1000+ knowledge bases, each with concurrency=5, never exceeds globalMaxConcurrency=50 simultaneous running jobs).
Business modules use TypeScript declaration merging to register type → payload mapping:
declare module '@main/core/job/jobRegistry' {
interface JobRegistry {
'agent.task': AgentTaskPayload
'knowledge.index-leaf': IndexLeafPayload
}
}
After this declaration:
jobManager.enqueue('agent.task', payload) is compile-time type-checkedenqueueTx)enqueue persists the row on the bare connection — fine when the enqueue is the only write. When a business-state flip and the job INSERT must commit atomically (e.g. mark items deleting and enqueue the purge job), use the transactional variant inside a DbService.withWriteTx callback:
application.get('DbService').withWriteTx((tx) => {
itemService.setStatusTx(tx, ids, 'deleting') // business write
return jobManager.enqueueTx(tx, 'my.purge', { ids }) // job INSERT, same tx
})
Post-commit side effects (state publish, dispatch / delayed arming) are deferred one microtask past the synchronous transaction. On rollback the row never existed: the returned handle's finished never resolves, and an idempotency-key unique-index collision aborts the whole caller transaction. See the enqueueTx JSDoc for the full contract.
registerJobScheduleTx / updateJobScheduleTx + syncJobScheduleTimerById)When a schedule row and a related business write must commit atomically, compose the transactional primitives inside a DbService.withWriteTx callback, then sync the timer after the transaction returns:
const { id } = application.get('DbService').withWriteTx((tx) => {
const created = jobManager.registerJobScheduleTx(tx, { type: 'agent.task', ... }) // schedule row
agentChannelService.replaceTaskSubscriptionsTx(tx, created.id, channelIds) // business write, same tx
return created
})
jobManager.syncJobScheduleTimerById(id) // post-commit timer sync (create: always; update: when the patch carried trigger/enabled)
The *Tx primitives validate up front (handler, name, trigger semantics → JOB_SCHEDULE_TRIGGER_INVALID) and never touch the timer, so a rollback has zero timer side effects. Timer sync is the caller's explicit post-commit step — enqueueTx's post-commit re-read cannot be reused here because its rollback test ("row absent") only holds for INSERT; an UPDATE rollback would read as committed and re-arm, resetting the interval phase. See the registerJobScheduleTx JSDoc for the full contract.
The renderer never enqueues, cancels, or otherwise mutates jobs through the DataApi. It only observes job state read-only:
useJob(jobId) → current JobSnapshot (status / counters / error / ...). Source: shared cache jobs.state.${id} with GET /jobs/:id as a cold-start fallback.useJobProgress(jobId) → fine-grained progress. Source: shared cache jobs.progress.${id} only.Triggering a job is owned by the relevant business module in main:
application.get('JobManager').enqueue(...) directly.knowledge.add_items IpcApi route); the route handler internally calls JobManager.enqueue(...).Schedule mutations (CRUD / pause / resume / run-now) follow the same pattern: renderer → dedicated IpcApi route (e.g. ai.agent.task.* → AgentJobsService) → JobManager schedule APIs; schedule reads stay on the GET-only DataApi.
This keeps JobRegistry's compile-time JobPayloadOf<K> type safety intact and prevents the renderer from depending on JobManager infrastructure details (queue names, retry policies, idempotency keys).