Minimal background workers for Clojure services.
Noun: (Finnish): agent, operative
agentti provides a small, explicit framework for running periodic background tasks inside a long-running JVM process.
It is designed for internal services and data pipelines that need a handful of highly reliable background jobs, but do not require heavyweight infrastructure.
Agentti was developed to meet the internal security and operational requirements of Sturdy Statistics. It is published as open source to support transparency, auditability, and reuse, but its design is intentionally conservative and driven by real production needs. We may not accept feature requests that dilute its focus.
warning
NOTE v0.2.0 represents a breaking change from v0.1.x.
agentti moved from using chime to run tasks to using core.async.
Its API changed slightly in the process.
Many Clojure and JVM services need a small number of background tasks but do not want the complexity of a full job system (Quartz, distributed queues).
agentti takes a deliberately resilient approach by splitting the problem in two:
java.time + chime): Pure generation of absolute-time sequences.core.async + java.util.concurrent): A core.async state machine that handles sleeping, timeouts, dropped ticks, and graceful shutdowns, with tasks run in dedicated JVM threads for cancellation.The result is a library that guarantees explicit execution semantics—tasks never overlap, timed-out threads are explicitly interrupted, and long-running schedules are immune to system clock drift. JVM interruption is cooperative, so a timed-out task that ignores interruption remains in flight; later ticks are dropped until it exits, after which the worker resumes normally.
Add to deps.edn:
{:deps {com.sturdystats/agentti {:mvn/version "VERSION"}}}
If you need persistence, distribution, or external coordination, this is not the right tool.
When configuring workers, agentti provides an optional agentti.schedule utility to generate bounded random walks.
When jitter is enabled, agentti intentionally applies it cumulatively between runs.
Each execution time is computed relative to the previous execution, not to a fixed wall clock.
This introduces a bounded random walk, so scheduled times gradually drift.
This behavior helps avoid synchronized “thundering herd” effects across workers and across processes, and favors de-correlation over strict calendar alignment.
Workers require a unique, nonblank string or keyword name; a positive integer timeout in milliseconds; a body function; and a seqable schedule of absolute times.
Keyword namespaces are preserved, so names such as :jobs/refresh and :refresh identify distinct workers.
Schedule items may be java.time.Instant, java.time.ZonedDateTime, java.util.Date, or epoch milliseconds.
A nil or empty schedule is accepted and registers a dormant worker with no scheduled ticks.
(require '[agentti.core :as agentti]
'[agentti.schedule :as sched])
(agentti/add-worker!
{:worker-name :session-pruner
;; sched/periodic-seq returns a lazy schedule sequence for intervals and jitter.
:schedule (sched/periodic-seq 10_000 {:jitter-frac 0.1})
;; Execution timeout; on expiry the thread is interrupted. JVM interruption is
;; cooperative, so later ticks are dropped until the timed-out task actually exits.
:timeout-ms 2000
;; The work to do
:body-fn (fn [] (println "Pruning expired sessions..."))})
;; later… (initiates a 3-second graceful shutdown, then forces interruption)
(agentti/stop-worker! :session-pruner)
warning
Java thread cancellation is cooperative. When a task exceeds :timeout-ms, agentti records a timeout and calls Future.cancel(true), which requests interruption of the executor thread—it cannot forcibly terminate the task.
If the task ignores interruption or is blocked in non-interruptible code, it remains :in-flight?. Later scheduled ticks are skipped and included in the worker's :dropped count; they are not queued and do not run concurrently.
The timeout appears in :last-error and contributes to :num-errors.
If the task eventually returns, the worker clears :in-flight? and resumes on a later tick.
A subsequent successful run clears :last-error, while the cumulative error and dropped counts remain available for monitoring.
Worker bodies should return promptly when interrupted.
Avoid swallowing InterruptedException, prefer interruptible operations, and configure timeouts on network and database calls rather than relying only on the worker timeout.
If work must be terminated rather than merely interrupted, isolate it in another process and enforce the deadline at the process boundary.
For strict, non-drifting calendar schedules (e.g., “Midnight on the 1st of the month”), pass a standard chime/periodic-seq.
(def ^:private ^ZoneId pacific-tz (ZoneId/of "America/Los_Angeles"))
(defn- daily-at-hour
"Returns a Chime schedule sequence that fires daily at the specified hour (0-23) in Pacific Time."
[hour]
(->> (chime/periodic-seq
(let [^ZonedDateTime now (ZonedDateTime/now pacific-tz)]
(-> now
(.withHour (int hour))
(.withMinute 0)
(.withSecond 0)
(.withNano 0))
(Period/ofDays 1))
(chime/without-past-times)))
(def daily-1am-schedule (daily-at-hour 1))
(def daily-2am-schedule (daily-at-hour 2))
(def daily-3am-schedule (daily-at-hour 3))
agentti tracks granular metrics for every worker, intended for direct exposure to internal admin endpoints or dashboards.
(require '[agentti.core :as agentti])
(agentti/list-workers)
;; =>
;; [{:worker-name "session-pruner"
;; :status :idle ;; :running, :idle, or :stopped
;; :running? true
;; :timeout-ms 2000
;; :num-runs 12
;; :num-errors 0
;; :last-error nil
;; :in-flight? false
;; :dropped 0 ;; Number of ticks dropped due to overlap
;; :last-run "2026-04-02T18:30:00Z"
;; :last-duration 87 ;; ms
;; :avg-duration 92 ;; ms
;; :uptime "14d 2h 5m 10s"
;; :since-last-run "10s"
;; :next-run-eta "2026-04-02T18:30:10Z"
;; :next-run-in "10s"}]
This is intended for internal admin endpoints or dashboards.
There is a theoretical race if a task reaches its timeout after submission but before its dedicated executor thread begins the callable.
Cancelling the Future prevents the callable from running, so it cannot report completion to the worker loop.
The worker can consequently remain :in-flight?, and later ticks will continue to be dropped until the worker or process is restarted.
In normal operation, each worker has a dedicated executor and permits only one in-flight task, so there should be no executor backlog. Encountering this race with a properly configured positive timeout would require severe JVM or OS scheduling starvation—an operational condition likely to require process or host recovery independently of agentti. Configure timeouts with enough margin for thread startup and normal scheduling delays.
stop-worker! removes the worker from the registry and initiates executor shutdown asynchronously; it does not wait for the task thread to terminate.
If an in-flight task ignores interruption, it may continue running after stop-worker! returns.
Immediately adding another worker with the same name can therefore run the replacement concurrently with the old task, and the old task is no longer represented in the dashboard.
Do not use stop-worker! followed immediately by add-worker! as a worker-restart mechanism.
Reuse a stopped worker name only when the application has independent confirmation that the previous task body exited.
If that confirmation is unavailable for interruption-resistant work, restart the process instead.
Apache License 2.0
Copyright © Sturdy Statistics
A note to Finnish speakers: We chose the name agentti in homage to metosin and out of admiration for the expressiveness of the Finnish language. We’re not Finnish speakers, so if we’ve misused the term, we apologize.
Can you improve this documentation? These fine people already did:
Michael McCourt & Mike McCourtEdit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |