molecule-scheduler
The default platform scheduler — BOTH halves of per-workspace self-scheduling in one plugin: the firing daemon AND the agent-callable schedule tool.
This plugin gives a workspace native scheduling. Its daemon reads the
workspace's schedule grid, computes the next fire time for each entry with the
host runtime's cron evaluator, and fires each due schedule as an autonomous
self-scheduler self-turn through the runtime's authenticated local A2A socket.
Fire-time state is durable on the workspace volume, so a restart never
double-fires or drops a schedule.
Alongside the daemon it contributes a self-audience MCP surface
(molecule-self) so the workspace's own agent can manage its schedules — see
Self-schedule MCP.
Self-schedule MCP
Beyond firing, the plugin gives the workspace's agent the tools to create and
manage its own schedules, via contributes.mcpServers (an audience: self entry
named molecule-self). It is the same @molecule-ai/mcp-server as the
concierge management MCP, run in MOLECULE_MCP_MODE=self so it exposes only the
six schedule verbs — list_schedules, create_schedule, update_schedule,
delete_schedule, run_schedule, get_schedule_history — scoped to the
workspace's own :id. Per the
plugin-mcp audience contract
(self-schedule v1), the runtime injects the workspace's own token as a re-read
file (MOLECULE_WORKSPACE_TOKEN_FILE=/configs/.auth_token) — a foreign id fails
closed, and no org/admin credential is ever injected here (that is the
separate concierge management MCP, audience: org). Because the descriptor is
runtime-agnostic, it renders into whichever native MCP config the active runtime
reads (claude-code settings.json, hermes/openclaw/codex their own) with no
per-runtime code. The npm range + --prefer-offline launcher mirror the concierge
fragment so both resolve the SAME prebaked @molecule-ai/mcp-server (mcp-pin
lockstep).
Per-install configuration
plugin.yaml declares a contributes.configuration block (plugin-manifest
contract, sdk#176), so an install can tune the daemon without editing this repo:
| Key | Type | Default | Meaning |
|---|---|---|---|
poll_seconds |
integer | 30 |
how often the daemon scans the grid for due schedules |
A workspace template sets it under the plugin entry:
plugins:
- source: gitea://molecule-ai/molecule-ai-plugin-scheduler#v0.2.0
config:
poll_seconds: 5
Core renders that to /configs/plugin-settings/molecule-scheduler.json at
provision; the workspace runtime resolves it at daemon discovery and
interpolates it into MOLECULE_TRIGGER_POLL_SECONDS. Declaring nothing keeps
today's behaviour exactly — the manifest's default: 30 is layer 1, resolved
on the box, so the daemon env is unchanged for every existing install.
The schedule grid is not configured here. It is volume-authoritative state
owned by the workspace (MOLECULE_TRIGGER_STATE_DIR), written by the schedule
API and seeded from the workspace template.
Why this exists
Scheduling used to be a central poll-and-fire loop inside molecule-core. The
scheduler-as-trigger-plugin RFC
retired that loop (core P4) and moved scheduling into the workspace runtime as a
kind: trigger plugin daemon — so scheduling is a per-workspace capability,
not a platform-wide singleton. A workspace runs the scheduler daemon iff this
plugin is installed (the runtime's has_trigger_daemon boot gate). Install it
in a workspace to give that workspace native scheduling; the platform also
ensures it is present for any workspace that has schedules.
Delivery
Installed like any other Molecule plugin, via MOLECULE_DECLARED_PLUGINS
(git-native boot install). The control plane adds this source to a workspace's
declared set when scheduling is enabled for it:
gitea://molecule-ai/molecule-ai-plugin-scheduler#<ref>
Schedules
The plugin ships an empty grid (schedules.yaml → schedules: []): it
provides the daemon, not preset schedules. Real schedules are written to the
workspace's durable volume grid by the runtime schedule API (POST /workspaces/:id/schedules, the ScheduleTab UI, webhooks). load_schedules()
prefers that durable copy over the bundled seed.
Knobs (env)
| Env | Default | Meaning |
|---|---|---|
MOLECULE_TRIGGER_STATE_DIR |
plugin dir | durable state/grid/health/history dir (runtime mounts the volume here) |
MOLECULE_TRIGGER_POLL_SECONDS |
30 |
tick interval — now declared as the poll_seconds install setting (see below); this env var is what the manifest interpolates into |
MOLECULE_TRIGGER_DELIVERY_ABSOLUTE_CAP_SECONDS |
3600 |
backstop used only when no turn-lease liveness signal can be obtained (see below) |
Durable files in the state dir: schedules.yaml (grid), schedule-state.json
(last fire times), schedule-pokes.json (RunNow/webhook), schedule-history.json
(bounded run log), schedule-health.json (last_tick/armed/errors).
Delivery liveness: activity, not wall-clock
message/send on the trigger lane returns only when the agent's turn completes,
so a delivery has no read deadline. How long a turn legitimately takes is a
property of the work, not the transport — and a flat HTTP timeout fires after
the request crossed the boundary, which is why the old fixed 600s cap produced
run-log entries that could only say status: unknown.
To tell a working agent from a wedged one the daemon reads the runtime's
turn lease (GET /turn-liveness, probe_trigger_liveness) — the platform's one
honest liveness signal, touched on every tool call. check_watchdog() then:
| lease says | action | recorded cause |
|---|---|---|
| alive | keep waiting, however long it runs | — |
idle_expired |
cancel + re-queue | idle |
absolute_cap_exceeded |
cancel + re-queue | absolute_cap |
| nothing (no lease / older host / unreachable) | cancel + re-queue past the ceiling | no_liveness_signal |
The absolute cap is judged before idleness, so continuous tool activity
cannot buy unlimited time. When the lease answers, its reported
absolute_cap_seconds wins over the local ceiling, so retuning the runtime does
not require retuning this plugin.
A cancelled fire is never dropped: durable state and the poke are left intact and the next tick re-enqueues it (at-least-once).
Every schedule-history.json entry carries a cause and a detail — completed,
idle, absolute_cap, no_liveness_signal, boundary_crossed,
capability_unavailable, delivery_failure — so a timeout says why instead of
leaving the next reader to infer it from lag clustering.
SSOT — do not hand-edit the daemon
scheduler.py, trigger_schedule.py, and channel_sdk.py are a materialized
instance of the SDK trigger scaffold (molecule-ai-sdk
molecule_plugin/templates/trigger) — the SDK is the single source of truth for
the scheduling daemon. CI regenerates the instance from the installed SDK and
byte-compares those three files (scripts/check_scaffold_drift.py); any
divergence fails the build. To change the daemon, change the SDK scaffold and
regenerate here (python -m molecule_plugin new --kind trigger molecule-scheduler),
never edit these files in place. Instance-owned files (plugin.yaml metadata,
the empty schedules.yaml, tests, CI) are not gated.