host RA: the Erlang durable runner adapter — built + tested (module + integration)

lib/host/ra.sx — a PURE-SX seam runner (advertises {effect,branch,each,suspend}) with an INJECTED
erl-eval (real = er-to-sx-deep ∘ erlang-eval-ast; mock in unit tests), so it loads in the plain host
(Erlang refs resolve lazily inside lambdas) and is unit-testable without the Erlang runtime.
host/ra--{atom,bin,erl-src,start-expr,resume-expr,parse,make-runner,resume,real-eval}: marshals our
canonical activity → Erlang source (CID as <<"…">> binary, atoms single-quoted), starts a named
next/ flow via flow_store, parses (ok Id (flow_done V))→{:status done :effects V :flow-id} /
(ok Id (flow_suspended T))→{:status suspended :resume {:id :tag}}.

DUAL-RUNNER ROUTING (flows.sx): host/flow--required-caps now handles a {:erl-flow :needs} DAG
(declared caps, since a foreign flow can't be introspected); host/flow--select-runner picks the
cheapest runner whose capabilities cover the DAG's needs. The capability model is now REAL with two
runners — an {effect,branch} composition lands on exec-runner; a {suspend} DAG routes to RA.

Verified: ra 9/9 (mock erl-eval) + plans/ra-integration.sh 4/4 (the REAL module driving live
flow_store: urgent→done, newsletter→suspended with a resume handle, digest_sent effect-as-data).
Full host conformance 607/607; next/tests/triggers_e2e.sh 10/10 baseline intact.

FINDING → RA-LIVE deferred: gen_servers don't persist across separate erlang-eval-ast calls (flow
README), so true cross-call suspend/resume needs a PERSISTENT next/ kernel process. The runner +
marshalling + suspend/resume mechanics are proven; RA-live is process lifecycle + wiring, documented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:20:36 +00:00
parent 17602e597f
commit c21be815f3
7 changed files with 245 additions and 14 deletions

62
lib/host/tests/ra.sx Normal file
View File

@@ -0,0 +1,62 @@
;; lib/host/tests/ra.sx — the RA (Erlang durable) runner adapter (lib/host/ra.sx), unit-tested with
;; a MOCK erl-eval (no Erlang runtime needed). The REAL dispatch is proven in plans/ra-spike.sh.
(define host-ra-pass 0)
(define host-ra-fail 0)
(define host-ra-fails (list))
(define host-ra-test
(fn (name actual expected)
(if (= actual expected)
(set! host-ra-pass (+ host-ra-pass 1))
(begin (set! host-ra-fail (+ host-ra-fail 1))
(append! host-ra-fails {:name name :actual actual :expected expected})))))
(define ra-urgent {:verb "create" :actor "site" :id "u1" :object-type "article" :category "urgent"})
(define ra-news {:verb "create" :actor "site" :id "n1" :object-type "article" :category "newsletter"})
;; ── marshalling: our canonical activity → Erlang source ──
(host-ra-test "erl-src marshals the activity → Erlang activity-proplist source"
(host/ra--erl-src ra-urgent)
"[{type, 'create'}, {actor, 'site'}, {id, <<\"u1\">>}, {object, [{type, 'article'}, {category, 'urgent'}]}]")
(host-ra-test "start-expr wraps it in flow_store:start with the env"
(host/ra--start-expr "bd" ra-urgent)
"flow_store:start(bd, [{activity, [{type, 'create'}, {actor, 'site'}, {id, <<\"u1\">>}, {object, [{type, 'article'}, {category, 'urgent'}]}]}, {actor, 'site'}])")
;; ── result parsing: flow_store's er-to-sx-deep'd result → the seam runner contract ──
(host-ra-test "parse DONE → {:status done :effects … :flow-id}"
(let ((p (host/ra--parse (list "ok" 1 (list "flow_done" (list "digest_sent"))))))
(list (get p :status) (get p :flow-id)))
(list "done" 1))
(host-ra-test "parse SUSPENDED → {:status suspended :resume {:id :tag}}"
(let ((p (host/ra--parse (list "ok" 1 (list "flow_suspended" "morning")))))
(list (get p :status) (get (get p :resume) :id) (get (get p :resume) :tag)))
(list "suspended" 1 "morning"))
(host-ra-test "parse garbage → failed"
(get (host/ra--parse (list "error" "boom")) :status) "failed")
;; ── the runner (MOCK erl-eval keyed on the marshalled src) ──
(define ra-mock
(fn (src) (if (>= (index-of src "newsletter") 0)
(list "ok" 7 (list "flow_suspended" "morning"))
(list "ok" 3 (list "flow_done" (list "digest_sent"))))))
(define ra-runner (host/ra--make-runner ra-mock))
(host-ra-test "RA runner advertises {effect, branch, each, suspend}"
(get ra-runner :capabilities) (list "effect" "branch" "each" "suspend"))
(host-ra-test "RA runner: urgent → done; newsletter → suspended (via the injected erl-eval)"
(list (get ((get ra-runner :run) {:erl-flow "bd"} {:activity ra-urgent}) :status)
(get ((get ra-runner :run) {:erl-flow "bd"} {:activity ra-news}) :status))
(list "done" "suspended"))
(host-ra-test "RA resume drives a suspended instance (async re-entry)"
(get (host/ra--resume ra-mock 7 "morning_ts") :status) "done")
;; ── dual-runner routing: the capability model, now REAL (2 runners) ──
(host-ra-test "select-runner: {effect,branch} composition → exec-runner; {suspend} dag → RA"
(let ((fleet (list host/flow--exec-runner ra-runner)))
(list (get (host/flow--select-runner fleet (quote (alt (when (eq "k" "v") (effect a)) (else (effect b))))) :capabilities)
(get (host/flow--select-runner fleet {:erl-flow "bd" :needs (list "effect" "branch" "suspend")}) :capabilities)))
(list (list "effect" "branch" "each") (list "effect" "branch" "each" "suspend")))
(define host-ra-tests-run!
(fn ()
{:total (+ host-ra-pass host-ra-fail)
:passed host-ra-pass :failed host-ra-fail :fails host-ra-fails}))