-
Notifications
You must be signed in to change notification settings - Fork 16
RFC 0014: Pluggable scheduler seam in gateway #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
amittell
wants to merge
13
commits into
openclaw:main
Choose a base branch
from
amittell:rfc/pluggable-scheduler
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+271
−0
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
93a62fc
RFC 0006: Pluggable scheduler seam in gateway
amittell 8b054c5
RFC 0006: backfill rfc_pr URL
amittell 4f5c88d
RFC 0006: clarify scheduler fallback restart
amittell 445b1f8
RFC 0006: align scheduler seam with cron surfaces
amittell 6257777
rfc: renumber pluggable scheduler RFC from 0006 to 0007
amittell 752a3d9
docs(rfc-0007): add status: draft and blank issue to frontmatter
amittell 82c24c6
rfc 0007: fold decided heartbeat and migration questions into text
amittell 8ef1054
rfc: renumber pluggable scheduler 0007 -> 0008
amittell f842358
rfc: fix sidecar path reference 0007 -> 0008
amittell f579eaa
rfc 0008: settle scheduler seam contract questions
amittell 0da9b0d
rfc: renumber pluggable scheduler to 0010
amittell baff8c1
rfc: renumber pluggable scheduler to 0014
amittell d17aeea
docs(rfc-0014): fold decided contract questions out of unresolved sec…
amittell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,271 @@ | ||
| --- | ||
| title: Pluggable Scheduler Seam in Gateway | ||
| authors: | ||
| - amittell | ||
| created: 2026-05-31 | ||
| last_updated: 2026-07-08 | ||
| rfc_pr: https://github.com/openclaw/rfcs/pull/5 | ||
| status: draft | ||
| issue: | ||
| --- | ||
|
|
||
| # Proposal: Pluggable Scheduler Seam in Gateway | ||
|
|
||
| ## Summary | ||
|
|
||
| OpenClaw's gateway currently ships a built-in cron/heartbeat layer that fires | ||
| scheduled jobs and gateway heartbeats from a single in-process scheduler. | ||
| Operators with workflows that need durability, retries, approvals, multi-step | ||
| chains, or full run history reach for external schedulers (notably | ||
| `openclaw-scheduler`, a SQLite-backed sibling service), but those tools have to | ||
| talk to the gateway through the public HTTP `cron.*` surface. Plugins can | ||
| already schedule Cron-backed session turns, but no external scheduler can | ||
| register itself as the canonical owner of scheduled work. This RFC proposes a | ||
| narrow plugin-SDK seam that lets an external scheduler plugin own scheduled-job | ||
| dispatch while the gateway keeps owning heartbeats and the core run-state | ||
| surfaces. | ||
|
|
||
| ## Motivation | ||
|
|
||
| The built-in cron is correct for the common case: one or two scheduled agent | ||
| prompts, low audit needs, every operator wants it to "just work." It is | ||
| intentionally scoped to in-process Cron semantics: | ||
|
|
||
| - Runs have gateway-owned run-log/status surfaces, but that history is tied to | ||
| built-in Cron and cannot represent an external scheduler's durable run graph. | ||
| - A job that needs `shell -> agent -> approval -> shell` cannot be expressed. | ||
| - Retry and concurrency policy is tied to the single Cron job lifecycle, not to | ||
| durable multi-step workflow state owned by another scheduler. | ||
| - The cron is in-process with the gateway, so a draining gateway pauses | ||
| scheduling for the duration of the drain. | ||
|
|
||
| Operators who need any of those affordances today install `openclaw-scheduler` | ||
| as a sibling service and disable the built-in cron, but that splits the | ||
| scheduling surface across two systems with no shared run-state model. The | ||
| gateway has Cron-backed plugin scheduling helpers and a `cron_changed` hook for | ||
| gateway-owned jobs, but an external scheduler still cannot mark itself as the | ||
| canonical owner of scheduled work or project its runs through the same status, | ||
| hook, run-log, and transcript surfaces. | ||
|
|
||
| ## Goals | ||
|
|
||
| - A narrow, additive seam in `openclaw/plugin-sdk` that lets a plugin register | ||
| itself as the canonical scheduler. | ||
| - Built-in cron remains the default and stays unchanged for operators who do | ||
| not install a scheduler plugin. | ||
| - A single contract for run-state ingestion so the gateway can surface | ||
| scheduler runs in `gateway status`, hooks, and transcript mirroring | ||
| regardless of which scheduler owns them. | ||
| - No new compiled dependencies in the gateway. The scheduler plugin owns its | ||
| own storage and runtime. | ||
|
|
||
| ## Non-Goals | ||
|
|
||
| - Replacing built-in cron with a different in-process scheduler. | ||
| - Defining a new persistence format for run state in core; the seam is | ||
| contract-only, the plugin owns its store. | ||
| - Migrating existing cron config into a scheduler plugin automatically. That | ||
| belongs in `openclaw doctor` follow-up work, not this seam. The same goes | ||
| for operators who run external schedulers via direct HTTP today: the | ||
| expected path is a doctor finding that suggests installing the scheduler | ||
| plugin, with details in a follow-up RFC. | ||
| - Bringing `openclaw-scheduler` into the core monorepo or as a bundled plugin; | ||
| this RFC is about the seam, not any specific implementation. | ||
|
|
||
| ## Proposal | ||
|
|
||
| Add a `scheduler` plugin kind to `openclaw/plugin-sdk` that exposes three | ||
| contracts. | ||
|
|
||
| ### Discovery contract (`openclaw.plugin.json`) | ||
|
|
||
| ```json | ||
| { | ||
| "kind": "scheduler", | ||
| "owns": "scheduled-jobs", | ||
| "contracts": { | ||
| "scheduledJobs": true, | ||
| "runState": true | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| When a `scheduler` plugin is registered and `owns: "scheduled-jobs"`, the | ||
| gateway disables its in-process cron at startup and routes all | ||
| `cron.*` scheduled-job calls to the plugin. If no scheduler plugin is | ||
| registered, the built-in cron stays on. The manifest shape above is additive: | ||
| implementation should extend the current plugin-kind and contract registry | ||
| rather than reinterpret any existing capability contract. | ||
|
|
||
| Fallback is also a startup decision in v1. If a scheduler plugin owns | ||
| scheduled jobs, uninstalling or disabling that plugin, or otherwise changing | ||
| scheduler ownership, requires a gateway restart before the built-in cron | ||
| resumes ownership. This keeps the first revision aligned with startup-time | ||
| discovery and avoids promising a hot scheduler handoff before the runtime has | ||
| a live ownership-transfer contract. | ||
|
|
||
| ### Runtime contract (`scheduler-host-runtime.ts`) | ||
|
|
||
| The plugin exports a runtime that the gateway calls in three places: | ||
|
|
||
| ```typescript | ||
| export interface SchedulerHostRuntime { | ||
| registerScheduledJob(spec: ScheduledJobSpec): Promise<ScheduledJobHandle>; | ||
| cancelScheduledJob(id: string): Promise<void>; | ||
| listScheduledJobs(): Promise<ScheduledJobSnapshot[]>; | ||
| } | ||
| ``` | ||
|
|
||
| When the scheduler plugin owns scheduled jobs, new or updated `cron.*` job | ||
| specs are forwarded to `registerScheduledJob`. The plugin owns scheduler | ||
| storage and runtime after registration, and the gateway treats | ||
| `listScheduledJobs` as the canonical catalog for list/status surfaces. Existing | ||
| built-in Cron stores are not migrated automatically in v1; they remain available | ||
| for the built-in fallback path or a follow-up `openclaw doctor` migration. | ||
|
|
||
| `cancelScheduledJob` is required in v1. A scheduler that can accept live | ||
| registration must also be able to cancel that live handle; otherwise the gateway | ||
| cannot make `cron.delete`, `cron.disable`, plugin uninstall, or rollback | ||
| semantics predictable. If a scheduler process is unavailable, the gateway | ||
| reports the cancel failure instead of silently promising that a next-startup | ||
| unregister will eventually happen. | ||
|
|
||
| Operator-facing list and status consumers should stay behind the gateway's | ||
| existing `cron.list`, `cron.status`, and `cron.runs` surfaces. The gateway may | ||
| call `listScheduledJobs` internally to back those surfaces when a scheduler | ||
| plugin owns scheduled jobs, but docs, tools, and channel integrations should not | ||
| depend on the scheduler runtime method directly. This keeps scheduler storage | ||
| private and leaves the public observability API stable if the runtime contract | ||
| changes. | ||
|
|
||
| ### Run-state contract (`scheduler-run-state.ts`) | ||
|
|
||
| The plugin pushes run lifecycle events into a scheduler-owned ingestion adapter | ||
| that feeds the gateway's existing cron event fan-out: | ||
|
|
||
| ```typescript | ||
| runState.onStart({ jobId, runId, startedAt, trigger }); | ||
| runState.onComplete({ jobId, runId, endedAt, exitCode, deliveredMessageIds }); | ||
| runState.onError({ jobId, runId, endedAt, error }); | ||
| ``` | ||
|
|
||
| The gateway uses these to (1) surface runs in `gateway status` and `cron.runs`, | ||
| (2) fire the existing `cron_changed` hook with the same public event shape used | ||
| for built-in cron, and (3) mirror or link agent-job transcripts when the plugin | ||
| run produces gateway-visible agent turns. The plugin does not have to integrate | ||
| with each consumer individually. | ||
|
|
||
| Run-state events cross a trust boundary from plugin-owned scheduler storage into | ||
| gateway-owned status, hook, and transcript-adjacent surfaces. The v1 seam must | ||
| therefore treat scheduler events as untrusted input: | ||
|
|
||
| - The gateway validates every event shape and rejects unknown fields that would | ||
| affect routing, transcript linkage, delivery state, or authorization. | ||
| - Every accepted event is tagged with the owning plugin id and scheduler job id | ||
| so status and audit views can distinguish plugin-projected runs from built-in | ||
| Cron runs. | ||
| - Events are deduplicated by `(pluginId, jobId, runId, eventType)` and accepted | ||
| only in a monotonic lifecycle order: `start -> complete | error`. | ||
| - Plugin events may link to gateway-visible agent turns that the gateway already | ||
| created, but they do not get to mint transcript records or delivery receipts | ||
| for sessions the gateway cannot verify. | ||
| - If the scheduler plugin is unavailable or emits invalid events, the gateway | ||
| marks the scheduler projection degraded and leaves built-in Cron fallback as a | ||
| restart-time ownership decision rather than attempting a hot failover. | ||
|
|
||
| A diagram of the discovery flow will be added under `rfcs/0014/` in a follow-up | ||
| revision once the seam shape is settled. In short: when the scheduler plugin | ||
| manifest declares `owns: "scheduled-jobs"`, the gateway swaps its built-in cron | ||
| registration for a forwarder to the plugin runtime, and ingests run lifecycle | ||
| through the scheduler run-state adapter. Heartbeats stay in core. | ||
|
|
||
| ## Acceptance gates | ||
|
|
||
| This RFC should stay in `draft` until maintainers accept the scheduler ownership | ||
| boundary and create the implementation issue required by the RFC lifecycle. | ||
| Acceptance should also record these gates for the first implementation PR: | ||
|
|
||
| - Scheduler ownership requires an explicit operator config opt-in in addition to | ||
| an enabled plugin manifest with `owns: "scheduled-jobs"`. | ||
| - The implementation must prove built-in Cron remains the default when no | ||
| scheduler plugin is enabled or when the explicit opt-in is absent. | ||
| - The implementation must prove restart-time fallback from scheduler-plugin | ||
| ownership back to built-in Cron when the plugin is disabled or uninstalled. | ||
| - The implementation must validate, provenance-tag, deduplicate, and lifecycle | ||
| order scheduler run-state events before projecting them into gateway-owned | ||
| status, hook, or transcript-adjacent surfaces. | ||
| - The implementation issue should be linked in the `issue` frontmatter before | ||
| this RFC is merged as accepted. | ||
|
|
||
| ## Rationale | ||
|
|
||
| ### Why a plugin-SDK seam instead of a config flag | ||
|
|
||
| A config flag (`scheduler.implementation: "builtin" | "external"`) was the | ||
| first thing I tried in my local fork. It collapses cleanly into the | ||
| operator-side mental model but does not give the external scheduler any way | ||
| to surface its runs through the gateway's existing observability surface. | ||
| Operators end up with two parallel run-state systems and a confused | ||
| `gateway status` view. | ||
|
|
||
| ### Why not move openclaw-scheduler into core | ||
|
|
||
| I run `openclaw-scheduler` on two gateways today and would happily contribute | ||
| the runtime, but bringing it in-tree fails three policy checks in | ||
| `AGENTS.md`: it adds a native dependency (`better-sqlite3`), it duplicates | ||
| the existing built-in cron rather than replacing it cleanly through a | ||
| deprecation path, and it is an "optional integration / service workflow" | ||
| which AGENTS.md routes to plugins or owner repos rather than core. The seam | ||
| proposed here lets the same code live as an external plugin while still | ||
| giving the gateway a first-class view of scheduled runs. | ||
|
|
||
| ### Why this seam is small | ||
|
|
||
| The runtime contract is three functions and three event shapes. The | ||
| discovery contract is a single manifest field and a single `owns` token. | ||
| This is intentionally narrower than what the existing cron does internally: | ||
| the seam covers scheduled jobs, not heartbeats. Heartbeats stay in core | ||
| because no external scheduler should be required for the gateway to consider | ||
| itself healthy. This is decided for v1: if a future host needs heartbeats | ||
| with job-grade durability, that would be a new `owns` token in a follow-up | ||
| RFC, not a widening of this seam. | ||
|
|
||
| ### Alternatives considered | ||
|
|
||
| - **Hook-only integration.** External schedulers could try to project run | ||
| updates through the existing `cron_changed` hook shape. This works for | ||
| observers, but does not give the gateway any way to know who owns the | ||
| schedule, so existing cron jobs and `cron.*` calls cannot be routed to the | ||
| plugin. | ||
| - **Replace built-in cron entirely with this seam.** Tempting, but breaks | ||
| every operator who has not installed a scheduler plugin and creates a | ||
| hard dependency on an external package for gateway readiness. | ||
| - **Bundle openclaw-scheduler as the canonical scheduler.** Discussed in the | ||
| Why-not-move-into-core section; rejected. | ||
|
|
||
| ## Existence proof | ||
|
|
||
| The consumer side already works. `openclaw-scheduler` has been deployed on | ||
| two gateways for several weeks, talking to OpenClaw only through the public | ||
| HTTP `cron.*` surface. Listing on ClawHub as `durable-scheduler` is live. Core | ||
| already has adjacent pieces for Cron-backed plugin scheduled turns, the | ||
| `cron_changed` hook, and cron run-log/status surfaces; the missing part is | ||
| startup-time scheduler ownership plus an adapter from plugin-owned runs into | ||
| those surfaces. If the seam shape proposed here is accepted, I am willing to | ||
| carry the reference implementation as a follow-up PR against `openclaw/openclaw`. | ||
|
|
||
| ## Unresolved questions | ||
|
|
||
| The two contract questions previously tracked here (implementation issue | ||
| before merge, explicit config opt-in) are decided text in the Acceptance | ||
| gates section. What remains out of scope for v1 but relevant: | ||
|
|
||
| - Hot scheduler ownership handoff without a gateway restart. v1 makes | ||
| ownership a startup decision; a live ownership-transfer contract would be | ||
| a follow-up RFC. | ||
| - Doctor migration for operators running external schedulers over direct | ||
| HTTP today. Folded into Non-Goals; the doctor finding and migration shape | ||
| belong in a follow-up RFC. | ||
| - Heartbeats with job-grade durability. Decided out of this seam; a future | ||
| host that needs them would introduce a new `owns` token in a follow-up | ||
| RFC rather than widening this one. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
status/issuekeys are part of the current repo schema, not extras. On currentmain:rfcs/0000-template.mdfrontmatter includes bothstatus: draftandissue:(lines 7-8).README.md("RFC Lifecycle") prescribes exactly this flow: "Open the RFC as a PR withstatus: draft", "Leaveissueblank until the RFC is accepted", then setissueto the implementation issue URL on acceptance.rfcs/0007-plugin-sdk-session-transcript-storage.md(status: approved,issue: ...),rfcs/0008-context-engine-runtime-settings.md(status: accepted,issue: ...),rfcs/0009-hosted-feeds-for-plugins-and-skills.md(status: draft).The README section cited (metadata keys without
status/issue) does not match currentmain's template or lifecycle text, so keeping the frontmatter as-is follows the live schema. No change made.