Recurring Tasks via Self-Scheduling (MoonBit)
Overview
A Golem agent can act as its own scheduler by calling schedule_ on its own client at the end of each invocation. This creates a durable, crash-resilient recurring task — if the agent restarts, the scheduled invocation is still pending and will fire at the designated time.
Basic Pattern
The agent schedules its own method to run again after a delay. Import "golemcloud/golem_sdk/interface/wasi/clocks/system-clock" @systemClock in moon.pkg. Scoped helpers and all their callers are async; the actual schedule enqueue methods are synchronous. The work helpers in these fragments must also be async if they perform async I/O:
#derive.agent
struct PollerAgent {
name : String
}
fn PollerAgent::new(name : String) -> PollerAgent {
{ name }
}
/// Kicks off the first poll
pub async fn PollerAgent::start(self : Self) -> Unit {
self.poll()
}
/// Does work and schedules itself to run again
pub async fn PollerAgent::poll(self : Self) -> Unit {
// 1. Do the recurring work
do_work()
// 2. Schedule the next run (60 seconds from now)
PollerAgentClient::scoped(self.name, async fn(client) {
let now = @systemClock.now()
let scheduled_at = @systemClock.Instant::{
seconds: now.seconds + 60,
nanoseconds: 0,
}
client.schedule_poll(scheduled_at)
})
}Exponential Backoff
Call start only once per agent. These basic recipes do not support restart or
repeated start: another start creates a second scheduling chain.
Increase the delay on repeated failures, reset on success:
#derive.agent
struct PollerAgent {
name : String
mut consecutive_failures : UInt
base_interval_secs : UInt64
max_interval_secs : UInt64
}
fn PollerAgent::new(name : String) -> PollerAgent {
{ name, consecutive_failures: 0, base_interval_secs: 60, max_interval_secs: 3600 }
}
pub async fn PollerAgent::poll(self : Self) -> Unit {
let success = try_work()
let delay = if success {
self.consecutive_failures = 0
self.base_interval_secs
} else {
self.consecutive_failures += 1
let exp = if self.consecutive_failures > 6 { 6U } else { self.consecutive_failures }
let backoff = self.base_interval_secs * pow2(exp)
if backoff > self.max_interval_secs { self.max_interval_secs } else { backoff }
}
PollerAgentClient::scoped(self.name, async fn(client) {
let now = @systemClock.now()
client.schedule_poll(@systemClock.Instant::{
seconds: now.seconds + delay.to_int64(),
nanoseconds: 0,
})
})
}Cancellation
Cancellation with CancellationToken
Durable generated clients have schedule_cancelable_{method} variants for methods with recursively stream-free inputs and outputs. They return a CancellationToken; ephemeral clients return a receipt containing the token and metadata. Call .cancel() on the token to prevent the scheduled invocation from firing:
#derive.agent
struct PollerAgent {
name : String
mut cancelled : Bool
mut pending_token : @agentHost.CancellationToken?
}
fn PollerAgent::new(name : String) -> PollerAgent {
{ name, cancelled: false, pending_token: None }
}
pub async fn PollerAgent::poll(self : Self) -> Unit {
if self.cancelled {
return
}
do_work()
PollerAgentClient::scoped(self.name, async fn(client) {
let now = @systemClock.now()
let token = client.schedule_cancelable_poll(@systemClock.Instant::{
seconds: now.seconds + 60,
nanoseconds: 0,
})
match self.pending_token {
Some(previous) => previous.drop()
None => ()
}
self.pending_token = Some(token)
})
}
pub fn PollerAgent::cancel(self : Self) -> Unit {
self.cancelled = true
match self.pending_token {
Some(token) => {
token.cancel()
token.drop()
self.pending_token = None
}
None => ()
}
}cancel() borrows the token; always drop it afterward. Drop a previous handle
before replacing it even if its invocation has already fired. This resource-bearing
state is not snapshot-serializable; do not include the token in a snapshot.
Cancellation via State Flag
For simpler cases, use a boolean flag — the next scheduled poll checks it and exits early. Cancellation is permanent: never reset the flag or restart this chain while an old tick may still be pending:
pub async fn PollerAgent::poll(self : Self) -> Unit {
if self.cancelled {
return
}
do_work()
self.schedule_next(60)
}
pub fn PollerAgent::cancel(self : Self) -> Unit {
self.cancelled = true
}Cancellation from the CLI
Schedule with an explicit idempotency key and cancel the pending invocation:
# Schedule with a known idempotency key
golem agent invoke --trigger --schedule-at 2026-03-15T10:30:00Z -i 'poll-next' 'PollerAgent("my-poller")' poll
# Cancel the pending invocation
golem agent cancel-invocation 'PollerAgent("my-poller")' 'poll-next'Common Use Cases
Periodic Polling
Check an external API or queue for new work at regular intervals:
pub async fn PollerAgent::poll(self : Self) -> Unit {
let items = fetch_pending_items()
for item in items {
process(item)
}
self.schedule_next(60)
}Periodic Cleanup
Remove expired data or stale resources on a schedule:
pub async fn PollerAgent::poll(self : Self) -> Unit {
self.entries = self.entries.filter(fn(e) { !e.is_expired() })
self.schedule_next(3600) // run hourly
}Heartbeat / Keep-Alive
Periodically notify an external service that the agent is alive:
pub async fn PollerAgent::poll(self : Self) -> Unit {
send_heartbeat(self.service_url)
self.schedule_next(30) // every 30s
}Helper for Scheduling Self
Extract the scheduling logic into a helper to keep methods clean:
async fn PollerAgent::schedule_next(self : Self, delay_secs : Int64) -> Unit {
PollerAgentClient::scoped(self.name, async fn(client) {
let now = @systemClock.now()
client.schedule_poll(@systemClock.Instant::{
seconds: now.seconds + delay_secs,
nanoseconds: 0,
})
})
}Key Points
- The agent is durable — if it crashes, the pending scheduled invocation still fires and the agent recovers
- Invocations are sequential — no concurrent executions of
pollon the same agent - Each
schedule_call is a synchronous fire-and-forget enqueue; the current invocation continues and completes when its method returns - Use a state flag to stop the loop gracefully
- Keep the scheduled method idempotent — it may be retried on recovery
Recovery & Oplog Growth
Each scheduled tick (heartbeat, poll, cleanup) appends entries to the agent’s oplog. For long-running or high-frequency recurring tasks, the oplog grows unboundedly, and recovery on crash will replay the full history — which becomes slow over time.
You cannot opt out of oplog writes for a durable agent. The fix is snapshot-based recovery: enable periodic snapshotting so recovery starts from the latest snapshot instead of replaying every prior tick. See golem-custom-snapshot-moonbit for the #derive.agent(snapshotting="every_n(N)") attribute and the Snapshottable trait.
For snapshots, use the state-flag recipe with serializable state, not the
pending_token field. The cleanup and heartbeat snippets are alternative poll
bodies and therefore use the same schedule_next helper. For restart support,
store a serializable generation counter and pass it to every scheduled tick;
reject ticks from an older generation before doing work or scheduling again.