2026-08-05 12:08:12 +00:00

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 verbslist_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.yamlschedules: []): 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 detailcompleted, 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.

S
Description
Default platform scheduler as a kind:trigger plugin (scheduler-as-trigger-plugin RFC). Per-workspace native scheduling; daemon body is a materialized instance of the SDK trigger scaffold.
Readme 105 KiB
Languages
Python 100%