Language guide

AgentSpaces for Clojure

Write an agent as one defagent form and start a peer from a plain config map. The Clojure library drives the Java binder, so a Clojure peer joins any AgentSpaces fleet with every verb and capability a Java peer has.

Clojure 1.11 or newer on Java 21 or newerai.badmonkey.agentspaces/agentspaces-clj 0.5.0 on Clojarsentry types are Java records, used as mapslistens and dials tcp and tlsevery verb, including the ordered log
aspace://guide/clojure/intro

Introduction

AgentSpaces lets programs coordinate through shared, replicated memory with no broker. The Clojure library, agentspaces-clj, hands you that memory as maps, keywords, and functions. A Clojure peer is a JVM peer, and it works in one group with Java, Python, TypeScript, and Ruby peers.

The words this guide uses:

  • Peer: one running process in the fabric. fleet/start makes one.
  • Group: the set of peers that share spaces. A peer joins a group through a seed, another peer it already knows.
  • Space: a named, replicated store of entries inside a group, such as orders.
  • Entry: one typed record in a space. In Clojure you write and read entries as maps.
  • Lease: how long an entry, or a claim on one, lives. An entry nobody renews disappears.
  • Take: an exclusive claim on an entry. If the taker dies before it completes, the take lease lapses and the entry comes back for another worker.
  • Agent: code bound to a peer that reacts to entries. defagent defines one.
  • Capability: a fleet-wide service a peer provides, such as voting or the ordered log.

Install

You need Java 21 or newer and the Clojure CLI. Add the library to deps.edn. It comes from Clojars and pulls the AgentSpaces Java libraries from Maven Central.

;; deps.edn
{:deps {ai.badmonkey.agentspaces/agentspaces-clj {:mvn/version "0.5.0"}}}

To build against a clone instead, replace :mvn/version with :local/root. The quick start project used below does that, and it also puts the compiled entry classes on the path:

;; deps.edn
{:paths ["classes"]
 :deps {ai.badmonkey.agentspaces/agentspaces-clj {:local/root "../../agentspaces-clj"}}}

Clojars or a local clone says when each one fits.

Entries are Java records

Every peer, in every language, agrees on entry types by name. On the JVM an entry type is a Java record, so you keep one small Java file per type. The shop in this guide starts with two:

// src/shop/Order.java
package shop;

public record Order(String orderId, String item) {
}
// src/shop/Shipment.java
package shop;

public record Shipment(String orderId, String item, String by) {
}

Compile them once into classes/:

mkdir -p classes && javac -d classes src/shop/*.java

After that you rarely touch the classes:

  • A write takes a map and builds the record through its constructor. A Clojure long fills an int field.
  • A read or a take returns a map. The record rides along in the map's metadata, and core/raw gets it back.
  • You can still build a record with interop, such as (Label. id address), wherever a map would do.

Your first agent

The fulfiller takes each Order from the space orders and writes a Shipment back. One file holds the agent and the peer that runs it.

;; fulfiller.clj
(ns shop.fulfiller
  (:require [agentspaces.agents :as agents :refer [defagent]]
            [agentspaces.fleet :as fleet]
            [agentspaces.space :as space])
  (:import (shop Order Shipment)))

(defagent Fulfiller
  {:description "Ships orders"}
  (take ship [^Order order] {:space "orders" :lease "30s"} ^Shipment
    {:orderId (:orderId order) :item (:item order) :by "fulfiller"}))

(let [[port seed item] *command-line-args*          ; clojure -M fulfiller.clj 7702 127.0.0.1:7701 kite
      system (fleet/start {:bind (str "127.0.0.1:" (or port 7701))
                           :group {:name "shop"
                                   :founding "shop-v1"
                                   :seeds (if seed [seed] [])
                                   :spaces [{:name "orders"}]}})]
  (agents/bind! system (->Fulfiller))
  (if item
    (let [orders (fleet/space system "orders")]
      (space/write! orders Order {:orderId "o-1" :item item} {:lease "10m"})
      (println (space/read orders Shipment {:timeout "30s"}))
      (fleet/stop! system))
    @(promise)))

What each part does:

  • defagent names the agent and lists its verbs. This one has a single take clause called ship.
  • [^Order order] names the entry type the clause takes. The order arrives as a map.
  • {:space "orders" :lease "30s"} are the verb's options. The agent may hold an order for 30 seconds before it returns to the space.
  • ^Shipment on the body names the entry type the clause writes. The body returns a plain map, and the library turns it into a Shipment.
  • fleet/start starts a peer from a config map. agents/bind! attaches the agent to the peer and starts its take loop.
  • Given an item argument, the program also writes an order and reads the shipment.

Run two instances

Compile the records. Start the first instance and leave it running, then run the second in another terminal:

clojure -M fulfiller.clj
clojure -M fulfiller.clj 7702 127.0.0.1:7701 kite

The first instance has no seeds, so it founds the group shop on port 7701. Both instances compute the same group id from the founding text shop-v1. The second binds port 7702, dials the first as its seed, writes order o-1, and prints the shipment as a map. Either instance may ship the order, because both run the fulfiller.

The quick start leaves :security out, so it runs under the default :dev-local profile. Set the profile in every config instead, as the tested shop programs do. They share this helper, and the examples below call it as peers/start:

;; peers.clj
(defn config
  "Peer config on 127.0.0.1:`port`; `seed` is a port or \"host:port\" (nil founds)."
  [port {:keys [seed spaces extra] :or {spaces [{:name "orders"}]}}]
  (merge {:bind (str "127.0.0.1:" port)
          :security {:profile :dev-local}
          :group {:name "shop" :founding "shop-v1"
                  :seeds (cond (nil? seed) [] (number? seed) [(str "127.0.0.1:" seed)] :else [seed])
                  :spaces spaces}}
         extra))

(defn start
  "Starts a peer on `port` with the shared settings."
  ([port] (start port {}))
  ([port opts] (fleet/start (config port opts))))
Note. The dev-local profile authenticates each connection with a signed handshake and sends frames over plain TCP. Use it on a laptop, never on a shared network. Production fleets use :security {:profile :mtls}.

A second verb: bid

In an auction space, the cheapest bidder gets the take. A bid clause returns a price as a double. Every peer registers the space with :strategy :auction, and the space collects bids for its settle window before it picks a winner.

;; step06.clj
;; every peer registers the space with the auction strategy: the lowest bid takes
(def spaces [{:name "auction" :strategy :auction :settle "500ms"}])

(defn price-a [order] (double (count (:item order))))   ; A bids the item's length
(defn price-b [_order] 5.0)                             ; B always bids 5

(defagent PricerA {:name "pricer" :description "Bids the item's length"}
  (bid price [^Order order] {:space "auction"} ^double (price-a order))
  (take ship [^Order order] {:space "auction"} ^Shipment
    {:orderId (:orderId order) :item (:item order) :by "A"}))
(defagent PricerB {:name "pricer" :description "Always bids 5"}
  (bid price [^Order order] {:space "auction"} ^double (price-b order))
  (take ship [^Order order] {:space "auction"} ^Shipment
    {:orderId (:orderId order) :item (:item order) :by "B"}))

Peer A binds PricerA and peer B binds PricerB. Peer A ships the kite and the tent for 4.0, and peer B ships the bicycle for 5.0. Both definitions carry {:name "pricer"} because a defagent takes no constructor arguments. Two peers that need different behavior bind two definitions under one agent name.

The other everyday verb is notify. A notify clause runs for every matching entry and leaves the entry in place, so several agents can each see it. Use it for receipts, tracking, and audit. defagent and its verbs lists the rest.

Where to read next

aspace://guide/clojure/features

Key features

One call starts a peer

fleet/start takes one map and returns a system map. The keys mirror the Spring starter's properties, so moving a config between a Clojure peer and a Spring Boot peer mostly means renaming keys. Only :group is required.

KeyWhat it sets
:bind"host:port" to listen on. Leave it out for a dial-only peer.
:security{:profile ...}, one of :dev-local, :mtls, :mtls-oidc, :zero-trust
:group:name, :founding, :seeds, :spaces, and the ways to join covered in Groups
:capabilitiesthe fleet services this peer provides, covered in Capabilities
:contentthe content swarm for large artifacts
:agentstrue publishes bound agents' cards even without a capability
:keystorea directory that keeps this peer's identity across restarts

The system map holds what fleet/start built. fleet/space returns a space by name, fleet/peer-id returns the peer's id, and fleet/stop! closes everything. The raw Java objects stay reachable too, under keys such as :node, :runtime, and :facade.

With no :security key the profile is :dev-local. The Spring starter defaults to mtls, so a config copied from one to the other changes its security silently. Write :security into every config. The OIDC profiles also need an Authorizer you build and pass under [:security :authorizer]. Read security levels before you pick one.

This peer joins a group founded by a Java peer on port 7701:

;; step11.clj
(def peer-d-config
  {:bind     "127.0.0.1:7704"
   :security {:profile :dev-local}
   :group    {:name "shop" :founding "shop-v1"
              :seeds ["127.0.0.1:7701"]                     ; the Java peer A
              :spaces [{:name "orders"}]}})

defagent and its verbs

Each clause in a defagent has the shape (verb method-name [^Type param] opts ^ReturnType body). The options are the Java annotation's attributes in kebab case: :space, :lease, :poll-timeout, :result-space, :tags, :where, :prefix, :key, :parts, :mode, and :produces. The macro writes a Java interface that carries the annotations, plus a constructor ->Name. The Java binder runs the result unchanged.

ClauseJava annotationWhat it doesNeeds
take@SpaceTakeclaims an entry and writes the resultnothing
notify@SpaceNotifyruns for each matching entry and leaves it in placenothing
bid@BidFunctionprices an entry in an auction spacea space with :strategy :auction
ballot@Ballotcasts one vote on each matching proposal:vote
on-decision@OnDecisionruns once when a vote closes:vote
propose@Proposeopens one vote per cue key:vote
join@SpaceJoinwaits for every part with one key, then fires once:ordered under :mode :ordered
reduce@SpaceReducefolds each element into an accumulator per key:ordered under :mode :ordered
ordered-take@OrderedTaketakes through the ordered log, exactly once:ordered
on-estimate@OnEstimateruns once when an aggregate estimate settles:aggregate

A clause body can return several things:

  • A map under a record hint becomes that record.
  • nil completes the take and writes nothing. In a ballot, nil abstains.
  • A vector forks into one entry per element. Leave the return hint off and list the types under :produces.
  • agents/motion opens a vote, agents/contribution feeds the aggregate, and agents/tagged writes an entry with tags.

Here a packer forks an Order into a Label and an Invoice, and an assembler joins the two by orderId into a Parcel:

;; step07.clj
(defagent Packer {:description "Splits an order into a label and an invoice"}
  (take pack [^Order order] {:space "orders" :produces [Label Invoice]}
    (println "packer forked" (:orderId order) "into Label and Invoice")
    [(Label. (:orderId order) "12 Vine St")
     (Invoice. (:orderId order) 2500)]))

(defagent Assembler {:description "Joins a label and an invoice into a parcel"}
  (join assemble [^Joined parts] {:space "orders" :key "orderId"
                                  :parts [{:value Label} {:value Invoice}]}
    ^Parcel
    (let [label   (core/from-entry (.get parts Label))
          invoice (core/from-entry (.get parts Invoice))]
      (println "assembler joined" (.key parts) "into Parcel")
      {:orderId (.key parts) :address (:address label) :cents (:cents invoice)})))

A join receives the Java Joined object. (.get parts Label) returns one part's record, and core/from-entry turns it into a map. The agent's name defaults to the lowercased form name, so this one is assembler. agents/card returns the bound agent's card as a map, with the types it consumes and produces.

Capabilities: the :capabilities map

Some verbs need a fleet service under them. A ballot needs the vote capability, for example. You switch services on with the :capabilities key, and agents/bind! fails with the missing capability's name if a verb needs one you left out. Each service also has a namespace of plain functions for use outside an agent.

ConfigNamespaceFunctions
:vote {:space "votes"}agentspaces.votepropose!, cast!, proposal, tally, decision, on-decision!
:aggregate trueagentspaces.aggregatestart!, estimate, await-settled, on-estimate!
:ordered {:space .. :members [..] :seed n}agentspaces.orderedtake!, complete!, leader?
:semantic trueagentspaces.semanticquery, remote-query
:learn true or {:metrics "metrics"}agentspaces.learnstart!, model, round, end-epoch!
:agents trueagentspaces.remoteactions, invoke!
:content {...}, a top-level keyagentspaces.contentpublish!, fetch!, await, progress, read-bytes, list-manifests

The content swarm moves large artifacts in pieces, from every peer that holds a copy. Here peer A publishes a 4 MiB checkpoint, and any other peer fetches it by its root:

;; step12.clj
;; each peer starts with this: the content swarm, with its pieces kept in memory
(def content-config
  {:content {:piece-size "256KiB"
             :store      {:backends [:memory]}}})

(defn publish [peer-a]
  (let [artifact (checkpoint)
        manifest (content/publish! peer-a artifact {:name "checkpoint-1"})]
    (println "peer A published checkpoint-1:" (:size manifest) "bytes")
    (sha-256 artifact)))

(defn fetch-checkpoint [peer]
  (let [manifest (wait/manifest-named peer "checkpoint-1")          ; replicated by the space
        fetch    (content/fetch! peer (:root manifest))]
    (when (content/await fetch "30s")
      fetch)))

In that program peer B fetches the checkpoint, peer A stops, and peer C still fetches every piece from B. wait/manifest-named polls content/list-manifests. Read capabilities and the content swarm for depth.

The REPL workflow

Agents bind and unbind at run time, so you can grow a fleet from the REPL. Start a REPL with clj in the project directory, after the records are in classes/. Then:

  1. Require the namespaces from the fulfiller's ns form, and (import '(shop Order Shipment)).
  2. Start a peer and keep its system map in a var: (def peer (fleet/start config)).
  3. Evaluate a defagent, then bind it and keep the handle: (def bound (agents/bind! peer (->Fulfiller))).
  4. Look around. (agents/card bound) shows the card, and (space/read-all (fleet/space peer "orders") Shipment) lists every shipment.
  5. Change the agent. Call (agents/unbind! bound), edit and re-evaluate the defagent, and bind it again.
  6. Call (fleet/stop! peer) when you are done, which frees the port.
Tip. A take that was already polling when you called unbind! can still claim one more entry. Wait a second or two before you write the next test entry, as the crash example does.

For pure data work, space/local makes a space with full lease semantics and no network. The same verbs and the same :where maps work on it:

;; REPL
(def work (space/local "work"))
(def h (space/write! work TaskEntry {:topic "indexing" :priority 3} {:lease "10m"}))
(space/read work TaskEntry {:where {:topic "indexing"} :timeout "1s"})
(space/read-all work TaskEntry {:where {:priority [:gte 3]} :limit 50})
(space/renew! h "10m")
(space/cancel! h)

A :where map matches plain values by equality. A vector names an operator, such as [:gte 3], [:in "us" "eu"], or [:contains "risk"]. Any Clojure function, such as odd?, works as a predicate.

Runs on the Java binder

The library reimplements nothing. defagent writes an annotated Java interface, and agents/bind! hands the object to the Java AgentBinder. A Clojure peer therefore has everything a Java peer has:

  • Every verb in the table above, including the ordered join and reduce.
  • A seat in the ordered log. A Clojure peer can be one of the log's voting members.
  • Every capability, the content swarm, agent keys, admission rules, and content keys.

Interop stays one step away: import records like any Java class, build one with (Label. ...), and call Java methods on the system map, such as (.allMembers (.membership (:runtime peer))).

Transports work like this:

  • :transport :tcp and :transport :tls select the two built-in transports, and the profile picks one for you. A :tls map with a trust store and CRLs switches on enterprise CA mode.
  • :transport also accepts any Java Transport object. The QUIC, DTLS, and WebSocket bindings ship as separate Java artifacts (agentspaces-transport-quic, agentspaces-transport-dtls, agentspaces-transport-http). Add the one you need to deps.edn and construct it yourself. The library's own tests cover TCP and TLS only.

Three choices differ from the Spring starter. One system serves one group, so start one system per group and share an identity through :identity. Spaces write as the agent clj, where the starter writes as app, so set :group {:agent "app"} when admission rules name the starter's agents. OIDC wiring is yours to add.

Groups: found, join by id, or use multicast

A config identifies its group in exactly one of three ways. fleet/start refuses a config that mixes them before it binds a port.

  • :founding "shop-v1": every member derives the same group id from the text. This is the simplest choice when members share configuration. Nobody can join such a group by id.
  • :found true: this peer signs the group's founding document, and the id is the hash of that document. fleet/group-uri returns the aspace:// address to hand out.
  • :join "aspace://...": the peer fetches the founding document from a seed, checks that it hashes to the id, and only then joins.
;; founder.clj
(def founder
  (fleet/start {:bind "127.0.0.1:7590"
                :group {:name "research" :found true
                        :spaces [{:name "tasks"}]}}))

(fleet/group-uri founder)   ;=> "aspace://zB3YQ..."  hand this out

A joining peer's :group repeats the :name, gives the address, and names at least one seed: {:name "research" :join (fleet/group-uri founder) :seeds ["127.0.0.1:7590"] :spaces [{:name "tasks"}]}. Add :security to both configs, as everywhere else.

On a LAN, :multicast true lets peers find each other with no seeds. Each peer sends its signed advertisement to 239.255.42.99:7787 every 5 seconds, and a receiver verifies it before it dials. {:multicast {:group "239.255.42.99:7787" :interval "2s"}} changes the address or the interval. Finding the group covers seeds, locators, and beacons in depth.

Clojars or a local clone

Choose the dependency by what you are doing:

  • Clojars ({:mvn/version "0.5.0"}) for applications. The jar holds source only, with no AOT, so it runs on whatever Clojure your project brings.
  • **:local/root** pointing at a clone, when you work on the library itself or need a commit newer than the release.

Either way the library consumes the Java artifacts by Maven coordinates, never by a path into a Java build. To try an unreleased Java core, run mvn install in its clone so the artifacts land in ~/.m2. The library's test suite runs real fleets over loopback in about fifteen seconds:

git clone https://github.com/badmonkeyai/agentspaces-clj.git
cd agentspaces-clj
clojure -M:test
aspace://guide/clojure/examples

Examples

The three examples come from tested programs. Each starts its peers on loopback ports 7701 to 7703 with the peers/start helper from Run two instances, so they all use the dev-local profile. Calls to wait/... are test plumbing that polls with a deadline. To run one, start a REPL as in The REPL workflow with peers.clj and the shop records on the path. Then evaluate the forms, start the peers, and call the function.

Crash recovery: a lease brings the order back

This example shows the heart of the model. Peer B takes an order and stops before it completes the take. Its lease lapses, the order reappears, and peer A ships it. No code on A handles the failure.

;; step05.clj
(defagent FulfillerA {:name "fulfiller" :description "Ships orders on peer A"}
  (take ship [^Order order] {:space "orders" :lease "30s"} ^Shipment
    {:orderId (:orderId order) :item (:item order) :by "A"}))
(defagent FulfillerB {:name "fulfiller" :description "Ships orders on peer B, slowly on o-7"}
  (take ship [^Order order] {:space "orders" :lease "3s"} ^Shipment
    (do (when (= "o-7" (:orderId order)) (Thread/sleep 60000))               ; B never finishes o-7
        {:orderId (:orderId order) :item (:item order) :by "B"})))

(defn crash [peer-a peer-b bound-a]
  (let [orders (fleet/space peer-a "orders")]
    (agents/unbind! bound-a)                                   ; only B's fulfiller is left
    (Thread/sleep 1500)                                        ; A's last 1 s poll ends
    (space/write! orders Order {:orderId "o-7" :item "tent"} {:lease "10m"})
    (wait/until-taken orders "o-7")                            ; B's fulfiller holds o-7
    (fleet/stop! peer-b)
    (agents/bind! peer-a (->FulfillerA))                       ; A's fulfiller is back
    (when-let [s (space/read orders Shipment {:where {:orderId "o-7"} :timeout "20s"})]
      (println (:orderId s) "shipped by" (:by s) "after peer B stopped")
      s)))

How it works:

  • B's fulfiller holds each order under a 3 second lease and sleeps forever on o-7.
  • crash unbinds A's fulfiller, so only B can take o-7. wait/until-taken polls until o-7 is no longer readable.
  • fleet/stop! stops peer B while it holds the order. Three seconds later the lease lapses and o-7 reappears.
  • A's fulfiller, bound again, takes o-7 and writes the shipment.

To run it, start (def peer-a (peers/start 7701)) and (def peer-b (peers/start 7702 {:seed 7701})), bind (->FulfillerA) on A and (->FulfillerB) on B, and call crash with A's handle. It prints o-7 shipped by A after peer B stopped. The full program first ships six orders through both fulfillers, each exactly once.

The library's repository runs the same kill test with two fulfiller processes in its quick start: agentspaces-clj.

A vote across three peers

Three peers provide the vote capability. Councillors on A and B vote yes, a slow sceptic on C votes no, and a clerk prints the decision once.

;; step09.clj
;; every peer registers the space votes and starts with this under :capabilities
(def capabilities {:vote {:space "votes"}})

(defagent Councillor {:description "Votes yes on releases"}
  (ballot judge [^VoteCapability$Proposal p] {:space "votes" :prefix "release-"}
    ^String (str "yes")))
(defagent Sceptic {:name "councillor" :description "Votes no on releases, slowly"}
  (ballot judge [^VoteCapability$Proposal p] {:space "votes" :prefix "release-"}
    ;; a quorum of 2 closes on the first two ballots; C's no arrives after that
    ^String (do (Thread/sleep 3000) "no")))

(defagent Clerk {:description "Announces each release decision"}
  (on-decision announce [^VoteCapability$Decision d] {:space "votes" :prefix "release-"}
    (println (str "decision " (:proposalId d) ": " (:winner d)))))

(defn hold-vote [peer-a peer-b peer-c]
  (agents/bind! peer-a (->Councillor))
  (agents/bind! peer-b (->Councillor))
  (agents/bind! peer-c (->Sceptic))
  (agents/bind! peer-a (->Clerk))
  (vote/propose! peer-a {:id "release-1.0" :question "Ship 1.0?"
                         :options ["yes" "no"] :quorum 2}))

(def vote-peer {:spaces [{:name "votes"}] :extra {:capabilities capabilities}})

How it works:

  • :prefix "release-" limits each clause to proposals whose id starts with release-.
  • A ballot body returns the chosen option as a string. The return hint goes on the body's first form, so a literal is wrapped in (str ...).
  • vote/propose! opens the vote with a quorum of 2. The two yes ballots close it before C's no arrives.
  • on-decision runs once for the closed vote. The proposal and decision arrive as maps, so (:winner d) works.

To run it, start (peers/start 7701 vote-peer), then (peers/start 7702 (assoc vote-peer :seed 7701)) and the same on 7703. Wait until all three see each other, and call hold-vote. The clerk prints decision release-1.0: yes, and (vote/decision peer-a "release-1.0") returns the tally. The same vote runs as a test in layer4_test.clj.

Exactly once through the ordered log

The ordered log sends every take through a replicated log the members agree on, so each entry is handled exactly once even across a network split. Here three payers on three peers pay one payment exactly once. The same peers also average their loads with the push-sum aggregate.

;; step10.clj
;; the ordered log names its members up front, so A, B, and C get their identities first
(def identities (vec (repeatedly 3 #(PeerIdentity/generate))))

;; each peer starts with these under :capabilities; n is 0, 1, 2 for A, B, C
(defn capabilities [n]
  {:aggregate true
   :ordered   {:space   "payments"
               :members (mapv #(.peerId %) identities)
               :seed    (inc n)}})                                  ; its own election-timer seed

(defagent Payer {:description "Pays each payment exactly once"}
  (ordered-take pay [^Payment p] {:space "payments"} ^Paid
    {:paymentId (:paymentId p) :cents (:cents p) :by "payer"}))

(defn average-load [peer-a peer-b peer-c]
  (aggregate/start! peer-a "load" 10)
  (aggregate/start! peer-b "load" 20)
  (aggregate/start! peer-c "load" 30)
  (when-let [average (aggregate/await-settled peer-a "load" {:ticks 5 :tolerance 0.001})]
    (println (format "average load settled at %.1f" average))
    average))

(defn pay-once [peer-a peer-b peer-c]
  (doseq [peer [peer-a peer-b peer-c]]
    (agents/bind! peer (->Payer)))                    ; three payers, one log
  (let [payments (fleet/space peer-a "payments")]
    (space/write! payments Payment {:paymentId "p-1" :cents 2500} {:lease "10m"})
    (when (wait/exactly-one? payments Paid)
      (println "paid p-1 exactly once")
      true)))

(defn -main [& _]
  ;; ...
  (let [start (fn [n port seed]
                (peers/start port {:seed seed :spaces [{:name "payments"}]
                                   :extra {:identity (identities n) :capabilities (capabilities n)}}))
        peer-a (start 0 7701 nil)
        peer-b (start 1 7702 7701)
        peer-c (start 2 7703 7701)]
    ;; ...
    (wait/check! (average-load peer-a peer-b peer-c) "the average load never settled")
    (wait/check! (pay-once peer-a peer-b peer-c) "p-1 was not paid exactly once")
    (run! fleet/stop! [peer-c peer-b peer-a]))
  (wait/done!))

How it works:

  • The log runs over a fixed member list, so the program generates the three identities first. Each peer starts with its own identity under :identity and the same member list under :ordered.
  • :seed gives each member its own election timer, so the members elect a leader quickly.
  • All three peers bind a payer. The log hands p-1 to exactly one of them, which writes one Paid.
  • aggregate/start! gives each peer's share, and await-settled returns the average once it stops moving: 20.0.

To run it, evaluate the forms and the start function from -main in the REPL, start the three peers, and call average-load and then pay-once. It prints average load settled at 20.0 and paid p-1 exactly once. The ordered and aggregate tests in layer4_test.clj do the same over two peers. For how the log decides a take, read the ordered log.